diff --git a/.DS_Store b/.DS_Store index 82727ea..14ddade 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml deleted file mode 100644 index ee7c601..0000000 --- a/.gitea/workflows/build.yaml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/helm.yaml b/.gitea/workflows/helm.yaml deleted file mode 100644 index 23ab44e..0000000 --- a/.gitea/workflows/helm.yaml +++ /dev/null @@ -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 diff --git a/.vscode/launch.json b/.vscode/launch.json index 06509dd..0eeb980 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,10 +2,10 @@ "version": "0.2.0", "configurations": [ { - "name": "Web (BFF)", + "name": "EntKube.Web", "type": "coreclr", "request": "launch", - "preLaunchTask": "build-web", + "preLaunchTask": "build", "program": "${workspaceFolder}/src/EntKube.Web/bin/Debug/net10.0/EntKube.Web.dll", "args": [], "cwd": "${workspaceFolder}/src/EntKube.Web", @@ -15,79 +15,8 @@ "pattern": "\\bNow listening on:\\s+(https?://\\S+)" }, "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5000" + "ASPNETCORE_ENVIRONMENT": "Development" } - }, - { - "name": "Clusters API", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build-clusters", - "program": "${workspaceFolder}/src/EntKube.Clusters/bin/Debug/net10.0/EntKube.Clusters.dll", - "args": [], - "cwd": "${workspaceFolder}/src/EntKube.Clusters", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5010" - } - }, - { - "name": "Identity API", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build-identity", - "program": "${workspaceFolder}/src/EntKube.Identity/bin/Debug/net10.0/EntKube.Identity.dll", - "args": [], - "cwd": "${workspaceFolder}/src/EntKube.Identity", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5030" - } - }, - { - "name": "Provisioning API", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build-provisioning", - "program": "${workspaceFolder}/src/EntKube.Provisioning/bin/Debug/net10.0/EntKube.Provisioning.dll", - "args": [], - "cwd": "${workspaceFolder}/src/EntKube.Provisioning", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5020" - } - }, - { - "name": "Secrets API", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build-secrets", - "program": "${workspaceFolder}/src/EntKube.Secrets/bin/Debug/net10.0/EntKube.Secrets.dll", - "args": [], - "cwd": "${workspaceFolder}/src/EntKube.Secrets", - "stopAtEntry": false, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://localhost:5040" - } - } - ], - "compounds": [ - { - "name": "All Services", - "configurations": [ - "Web (BFF)", - "Clusters API", - "Identity API", - "Provisioning API", - "Secrets API" - ], - "preLaunchTask": "build-all", - "stopAll": true } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bfc5f33..651d77a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": "build-web", + "label": "build", "command": "dotnet", "type": "process", "args": [ @@ -14,83 +14,14 @@ "problemMatcher": "$msCompile" }, { - "label": "build-clusters", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/EntKube.Clusters/EntKube.Clusters.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "build-identity", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/EntKube.Identity/EntKube.Identity.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "build-provisioning", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/EntKube.Provisioning/EntKube.Provisioning.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "build-secrets", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/EntKube.Secrets/EntKube.Secrets.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile" - }, - { - "label": "build-all", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/EntKube.slnx", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary;ForceNoAlign" - ], - "problemMatcher": "$msCompile", - "group": { - "kind": "build", - "isDefault": true - } - }, - { - "label": "test-all", + "label": "test", "command": "dotnet", "type": "process", "args": [ "test", - "${workspaceFolder}/EntKube.slnx", - "--no-build" + "${workspaceFolder}/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" ], - "problemMatcher": "$msCompile", - "group": { - "kind": "test", - "isDefault": true - } + "problemMatcher": "$msCompile" } ] } diff --git a/CMKS - Infra b/CMKS - Infra new file mode 160000 index 0000000..ac8bf53 --- /dev/null +++ b/CMKS - Infra @@ -0,0 +1 @@ +Subproject commit ac8bf53d54136fd15e8e8e8c6bad8a7b2175eb6a000000000000000000000000 diff --git a/Charts/entkube/.helmignore b/Charts/entkube/.helmignore deleted file mode 100644 index d3ce842..0000000 --- a/Charts/entkube/.helmignore +++ /dev/null @@ -1,4 +0,0 @@ -# Patterns to ignore when packaging the chart -.DS_Store -*.tgz -.git/ diff --git a/Charts/entkube/Chart.yaml b/Charts/entkube/Chart.yaml deleted file mode 100644 index a6a531b..0000000 --- a/Charts/entkube/Chart.yaml +++ /dev/null @@ -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 diff --git a/Charts/entkube/templates/_helpers.tpl b/Charts/entkube/templates/_helpers.tpl deleted file mode 100644 index af2bef4..0000000 --- a/Charts/entkube/templates/_helpers.tpl +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/clusters-deployment.yaml b/Charts/entkube/templates/clusters-deployment.yaml deleted file mode 100644 index 6eee296..0000000 --- a/Charts/entkube/templates/clusters-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/clusters-service.yaml b/Charts/entkube/templates/clusters-service.yaml deleted file mode 100644 index aa3a6d6..0000000 --- a/Charts/entkube/templates/clusters-service.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/identity-deployment.yaml b/Charts/entkube/templates/identity-deployment.yaml deleted file mode 100644 index fed23ab..0000000 --- a/Charts/entkube/templates/identity-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/identity-service.yaml b/Charts/entkube/templates/identity-service.yaml deleted file mode 100644 index 7899e4c..0000000 --- a/Charts/entkube/templates/identity-service.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/ingress.yaml b/Charts/entkube/templates/ingress.yaml deleted file mode 100644 index b096479..0000000 --- a/Charts/entkube/templates/ingress.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/provisioning-deployment.yaml b/Charts/entkube/templates/provisioning-deployment.yaml deleted file mode 100644 index 5ea565c..0000000 --- a/Charts/entkube/templates/provisioning-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/provisioning-service.yaml b/Charts/entkube/templates/provisioning-service.yaml deleted file mode 100644 index c5a8903..0000000 --- a/Charts/entkube/templates/provisioning-service.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/secrets-deployment.yaml b/Charts/entkube/templates/secrets-deployment.yaml deleted file mode 100644 index 3e3a11c..0000000 --- a/Charts/entkube/templates/secrets-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/secrets-service.yaml b/Charts/entkube/templates/secrets-service.yaml deleted file mode 100644 index 4ab2715..0000000 --- a/Charts/entkube/templates/secrets-service.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/serviceaccount.yaml b/Charts/entkube/templates/serviceaccount.yaml deleted file mode 100644 index 7d4da49..0000000 --- a/Charts/entkube/templates/serviceaccount.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/web-deployment.yaml b/Charts/entkube/templates/web-deployment.yaml deleted file mode 100644 index 23cc2b2..0000000 --- a/Charts/entkube/templates/web-deployment.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/templates/web-service.yaml b/Charts/entkube/templates/web-service.yaml deleted file mode 100644 index b96ac7e..0000000 --- a/Charts/entkube/templates/web-service.yaml +++ /dev/null @@ -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 }} diff --git a/Charts/entkube/values-dev.yaml b/Charts/entkube/values-dev.yaml deleted file mode 100644 index fb073d2..0000000 --- a/Charts/entkube/values-dev.yaml +++ /dev/null @@ -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: {} diff --git a/Charts/entkube/values.yaml b/Charts/entkube/values.yaml deleted file mode 100644 index b515e19..0000000 --- a/Charts/entkube/values.yaml +++ /dev/null @@ -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: {} diff --git a/Directory.Build.props b/Directory.Build.props index 569df69..0d4aef4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,5 @@ - false false - false diff --git a/EntKube.slnx b/EntKube.slnx index cbd8e1f..b21d15f 100644 --- a/EntKube.slnx +++ b/EntKube.slnx @@ -1,16 +1,9 @@ - - - - - - - - - - - - - - + + + + + + + diff --git a/Tiltfile b/Tiltfile deleted file mode 100644 index ce0ceff..0000000 --- a/Tiltfile +++ /dev/null @@ -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']) diff --git a/src/EntKube.Clusters/Dockerfile b/src/EntKube.Clusters/Dockerfile deleted file mode 100644 index 0ec1f22..0000000 --- a/src/EntKube.Clusters/Dockerfile +++ /dev/null @@ -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"] diff --git a/src/EntKube.Clusters/Domain/ClusterComponent.cs b/src/EntKube.Clusters/Domain/ClusterComponent.cs deleted file mode 100644 index f3637b3..0000000 --- a/src/EntKube.Clusters/Domain/ClusterComponent.cs +++ /dev/null @@ -1,83 +0,0 @@ -using EntKube.Clusters.Features.AdoptCluster; - -namespace EntKube.Clusters.Domain; - -/// -/// Represents a tracked component on a cluster. Once detected during an adoption -/// scan, the component is persisted so subsequent page loads can display state -/// without hitting the cluster API again. Configuration changes (via deploy or -/// configure operations) update this record in-place to reflect current state. -/// -public class ClusterComponent -{ - public string ComponentName { get; private set; } = string.Empty; - public ComponentStatus Status { get; private set; } - public string? Version { get; private set; } - public string? Namespace { get; private set; } - public string? HelmReleaseName { get; private set; } - public Dictionary Configuration { get; private set; } = new(); - public DateTimeOffset LastCheckedAt { get; private set; } - - private ClusterComponent() { } - - /// - /// Creates a component record from an adoption check result. This captures - /// a snapshot of what the scan discovered — status, version, namespace, - /// Helm release, and running configuration values. - /// - public static ClusterComponent FromCheckResult(ComponentCheckResult result) - { - return new ClusterComponent - { - ComponentName = result.ComponentName, - Status = result.Status, - Version = result.Configuration?.Version, - Namespace = result.Configuration?.Namespace, - HelmReleaseName = result.Configuration?.HelmReleaseName, - Configuration = result.Configuration?.Values ?? new Dictionary(), - LastCheckedAt = DateTimeOffset.UtcNow - }; - } - - /// - /// Merges new configuration values into the existing configuration. - /// Existing keys are overwritten, new keys are added, keys not in the - /// update are left unchanged. - /// - public void MergeConfiguration(Dictionary values) - { - foreach (KeyValuePair kvp in values) - { - Configuration[kvp.Key] = kvp.Value; - } - } - - /// - /// After a successful deployment, mark the component as Installed. - /// - public void MarkInstalled() - { - Status = ComponentStatus.Installed; - LastCheckedAt = DateTimeOffset.UtcNow; - } - - /// - /// Updates the tracked version after a successful upgrade. The version - /// string comes from Helm or the installer and reflects what's now - /// running on the cluster. - /// - public void UpdateVersion(string version) - { - Version = version; - LastCheckedAt = DateTimeOffset.UtcNow; - } - - /// - /// After a successful uninstall, mark the component as NotInstalled. - /// - public void MarkUninstalled() - { - Status = ComponentStatus.NotInstalled; - LastCheckedAt = DateTimeOffset.UtcNow; - } -} diff --git a/src/EntKube.Clusters/Domain/IClusterRepository.cs b/src/EntKube.Clusters/Domain/IClusterRepository.cs deleted file mode 100644 index c5a5501..0000000 --- a/src/EntKube.Clusters/Domain/IClusterRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace EntKube.Clusters.Domain; - -/// -/// 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. -/// -public interface IClusterRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> 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); -} diff --git a/src/EntKube.Clusters/Domain/KubernetesCluster.cs b/src/EntKube.Clusters/Domain/KubernetesCluster.cs deleted file mode 100644 index bca26ae..0000000 --- a/src/EntKube.Clusters/Domain/KubernetesCluster.cs +++ /dev/null @@ -1,274 +0,0 @@ -using EntKube.Clusters.Features.AdoptCluster; - -namespace EntKube.Clusters.Domain; - -/// -/// A KubernetesCluster represents a registered cluster that EntKube manages. -/// It holds the connection details, health state, and metadata needed to -/// interact with the cluster's API server. Every cluster belongs to exactly -/// one tenant — no cluster can exist without an owning tenant. The kubeconfig -/// is stored as the credential material for API access. -/// -public class KubernetesCluster -{ - public Guid Id { get; private set; } - public Guid TenantId { get; private set; } - public Guid EnvironmentId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string ApiServerUrl { get; private set; } = string.Empty; - public string ContextName { get; private set; } = string.Empty; - public ClusterStatus Status { get; private set; } - public CloudProvider Provider { get; private set; } - public ProviderCredentials? ProviderCredentials { get; private set; } - public string KubeConfig { get; private set; } = string.Empty; - public DateTimeOffset RegisteredAt { get; private set; } - public DateTimeOffset? LastHealthCheckAt { get; private set; } - public IReadOnlyList PrometheusEndpoints => prometheusEndpoints.AsReadOnly(); - public IReadOnlyList Components => components.AsReadOnly(); - public IReadOnlyList StorageBuckets => storageBuckets.AsReadOnly(); - - private List prometheusEndpoints = new(); - private List components = new(); - private List storageBuckets = new(); - - private KubernetesCluster() { } - - /// - /// Registers a new cluster in the platform. The caller must provide a tenant, - /// cluster name, API server URL, kubeconfig, and the environment it belongs to. - /// The cluster starts in a Pending state until a health check confirms it's reachable. - /// Every cluster must be assigned to an environment — no orphan clusters allowed. - /// - public static KubernetesCluster Register(string name, string apiServerUrl, string 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 - }; - } - - /// - /// After a successful health check, we mark the cluster as connected. - /// This means the platform can now schedule work against this cluster. - /// - public void MarkConnected() - { - Status = ClusterStatus.Connected; - LastHealthCheckAt = DateTimeOffset.UtcNow; - } - - /// - /// 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. - /// - public void MarkUnreachable() - { - Status = ClusterStatus.Unreachable; - LastHealthCheckAt = DateTimeOffset.UtcNow; - } - - /// - /// Assigns (or reassigns) this cluster to an environment. A cluster lives - /// in exactly one environment at a time — for example dev01 belongs to "dev", - /// test01 and test02 belong to "test", prod01 belongs to "prod". - /// - public void AssignToEnvironment(Guid environmentId) - { - if (environmentId == Guid.Empty) - { - throw new ArgumentException("Environment ID is required.", nameof(environmentId)); - } - - EnvironmentId = environmentId; - } - - /// - /// Replaces all discovered Prometheus endpoints with a fresh scan result. - /// Called after the platform scans the cluster for Prometheus services. - /// If multiple instances are found, they are all stored so metrics can - /// be aggregated from all of them. - /// - public void SetPrometheusEndpoints(List endpoints) - { - prometheusEndpoints = endpoints ?? new List(); - } - - /// - /// Replaces all tracked components with a fresh scan result from the adoption - /// check. Each component check result is converted into a ClusterComponent - /// that persists the detected state — status, version, namespace, config. - /// This is called after every adoption scan so stored state stays current. - /// - public void UpdateComponents(List checkResults) - { - components = checkResults - .Select(ClusterComponent.FromCheckResult) - .ToList(); - } - - /// - /// Merges new configuration values into an already-tracked component. - /// Called after a successful configure operation so the persisted config - /// reflects what's actually deployed. If the component hasn't been - /// tracked yet (no prior scan), this is a no-op. - /// - public void UpdateComponentConfiguration(string componentName, Dictionary values) - { - ClusterComponent? component = components.FirstOrDefault( - c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - - component?.MergeConfiguration(values); - } - - /// - /// Marks a tracked component as Installed after a successful deployment. - /// If the component hasn't been tracked yet, this is a no-op. - /// - public void MarkComponentInstalled(string componentName) - { - ClusterComponent? component = components.FirstOrDefault( - c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - - component?.MarkInstalled(); - } - - /// - /// Updates the version of a tracked component after a successful upgrade. - /// If the component hasn't been tracked yet, this is a no-op. - /// - public void UpdateComponentVersion(string componentName, string version) - { - ClusterComponent? component = components.FirstOrDefault( - c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - - component?.UpdateVersion(version); - } - - /// - /// Marks a tracked component as NotInstalled after a successful uninstall. - /// If the component hasn't been tracked yet, this is a no-op. - /// - public void MarkComponentUninstalled(string componentName) - { - ClusterComponent? component = components.FirstOrDefault( - c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - - component?.MarkUninstalled(); - } - - /// - /// Assigns a cloud provider to this cluster. Providers like Cleura offer - /// additional APIs (OpenStack, Gardener) that EntKube can leverage for - /// provisioning, billing, and resource management. Credentials are required - /// for any provider other than None. - /// - public void SetProvider(CloudProvider provider, ProviderCredentials? credentials) - { - if (provider != CloudProvider.None && credentials is null) - { - throw new ArgumentException( - $"Credentials are required when setting provider to {provider}.", - nameof(credentials)); - } - - Provider = provider; - ProviderCredentials = provider == CloudProvider.None ? null : credentials; - } - - /// - /// Registers a newly created storage bucket on this cluster. The bucket - /// has already been created on the S3 backend — this persists the metadata - /// so the platform knows about it and components can reference it. - /// - public void AddStorageBucket(StorageBucket bucket) - { - storageBuckets.Add(bucket); - } - - /// - /// Removes a storage bucket from this cluster's tracked buckets. - /// Called after the bucket has been deleted from the S3 backend. - /// - public void RemoveStorageBucket(Guid bucketId) - { - storageBuckets.RemoveAll(b => b.Id == bucketId); - } -} - -/// -/// The cloud provider hosting a Kubernetes cluster. Determines which additional -/// APIs are available for management (OpenStack, Gardener, billing, etc.). -/// -public enum CloudProvider -{ - None, - Cleura, - Aws, - Azure, - Gcp -} - -/// -/// Credentials for authenticating with a cluster's cloud provider APIs. -/// Cleura exposes two separate APIs: -/// 1. Cleura Cloud REST API (rest.cleura.cloud) — uses Cleura login credentials -/// for account management, Gardener K8s cluster ops, billing, domain listing. -/// 2. OpenStack API (e.g. sto2.citycloud.com:5000) — uses OpenStack/Keystone -/// credentials for infrastructure: compute, networking, storage, load balancing. -/// Both credential sets are stored here; either may be null if not configured. -/// -public record ProviderCredentials( - string Username, - string Password, - string Region, - string? OpenStackAuthUrl = null, - string? OpenStackProjectId = null, - string? OpenStackUsername = null, - string? OpenStackPassword = null, - string? OpenStackUserDomainName = null); - -public enum ClusterStatus -{ - Pending, - Connected, - Unreachable -} diff --git a/src/EntKube.Clusters/Domain/PrometheusEndpoint.cs b/src/EntKube.Clusters/Domain/PrometheusEndpoint.cs deleted file mode 100644 index 6fd85f0..0000000 --- a/src/EntKube.Clusters/Domain/PrometheusEndpoint.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace EntKube.Clusters.Domain; - -/// -/// A discovered Prometheus instance running inside a Kubernetes cluster. -/// Stores the connection URL, the namespace where it runs, and the service name. -/// Multiple Prometheus endpoints may exist per cluster — metrics are aggregated -/// from all of them when queried. -/// -public record PrometheusEndpoint(string Url, string Namespace, string ServiceName); diff --git a/src/EntKube.Clusters/Domain/StorageBucket.cs b/src/EntKube.Clusters/Domain/StorageBucket.cs deleted file mode 100644 index bfd7901..0000000 --- a/src/EntKube.Clusters/Domain/StorageBucket.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace EntKube.Clusters.Domain; - -/// -/// A managed S3-compatible storage bucket provisioned through the platform. -/// Tracks which credentials own the bucket, its encryption setting, and -/// connection information so components can wire up to it as a storage backend. -/// -public class StorageBucket -{ - public Guid Id { get; private set; } - public string Name { get; private set; } = string.Empty; - public string AccessKey { get; private set; } = string.Empty; - public string SecretKey { get; private set; } = string.Empty; - public string Endpoint { get; private set; } = string.Empty; - public string Region { get; private set; } = string.Empty; - public bool Encrypted { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - - private StorageBucket() { } - - /// - /// Creates a new storage bucket record after successfully provisioning - /// the bucket on the S3-compatible backend. The access key and secret key - /// are the credentials that own and can access this bucket. - /// - public static StorageBucket Create( - string name, - string accessKey, - string secretKey, - string endpoint, - string region, - bool encrypted) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Bucket name is required.", nameof(name)); - } - - return new StorageBucket - { - Id = Guid.NewGuid(), - Name = name, - AccessKey = accessKey, - SecretKey = secretKey, - Endpoint = endpoint, - Region = region, - Encrypted = encrypted, - CreatedAt = DateTimeOffset.UtcNow - }; - } -} diff --git a/src/EntKube.Clusters/EntKube.Clusters.csproj b/src/EntKube.Clusters/EntKube.Clusters.csproj deleted file mode 100644 index 6e18374..0000000 --- a/src/EntKube.Clusters/EntKube.Clusters.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - diff --git a/src/EntKube.Clusters/EntKube.Clusters.http b/src/EntKube.Clusters/EntKube.Clusters.http deleted file mode 100644 index b431c29..0000000 --- a/src/EntKube.Clusters/EntKube.Clusters.http +++ /dev/null @@ -1,6 +0,0 @@ -@EntKube.Clusters_HostAddress = http://localhost:5243 - -GET {{EntKube.Clusters_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterEndpoint.cs deleted file mode 100644 index 5b8393e..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// Maps the adoption endpoints: -/// - POST /api/clusters/{id}/adopt — runs all checks, returns aggregate report -/// - GET /api/clusters/{id}/components — lists available components and their status -/// -public static class AdoptClusterEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters/{id:guid}/adopt", async ( - Guid id, - AdoptClusterHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterHandler.cs b/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterHandler.cs deleted file mode 100644 index c8ae965..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/AdoptClusterHandler.cs +++ /dev/null @@ -1,84 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// Orchestrates the full adoption check by running all registered component checks -/// against the cluster. Each check runs independently — a failure in one doesn't -/// prevent the others from reporting their status. The results are aggregated into -/// a single AdoptionReport that tells the caller exactly what's installed, degraded, -/// or missing. -/// -/// Think of this as "terraform plan" for a cluster: it inspects current state -/// and reports the delta between what exists and what's required. -/// -public class AdoptClusterHandler -{ - private readonly IClusterRepository repository; - private readonly IEnumerable checks; - private readonly ILogger logger; - - public AdoptClusterHandler(IClusterRepository repository, IEnumerable checks, ILogger logger) - { - this.repository = repository; - this.checks = checks; - this.logger = logger; - } - - public async Task> HandleAsync(Guid clusterId, CancellationToken ct = default) - { - logger.LogWarning("[DIAG] AdoptClusterHandler.HandleAsync called for cluster {ClusterId} with {CheckCount} checks registered", clusterId, checks.Count()); - - // Find the cluster — can't adopt something that doesn't exist. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - // The cluster must be connected (reachable) so we can inspect it. - // A Pending or Unreachable cluster can't be checked. - - if (cluster.Status != ClusterStatus.Connected) - { - return Result.Failure( - "Cluster must be connected before adoption. Run a health check to verify connectivity."); - } - - // Run all component checks in parallel — they're independent of each other. - // Each check connects to the cluster API and inspects its own area. - - List> checkTasks = checks - .Select(check => check.CheckAsync(cluster, ct)) - .ToList(); - - ComponentCheckResult[] results = await Task.WhenAll(checkTasks); - - // Log what each check returned for diagnostics. - - foreach (ComponentCheckResult result in results) - { - logger.LogWarning("[DIAG] Check result: {Component} = {Status}, missing=[{Missing}], details=[{Details}]", - result.ComponentName, result.Status, - string.Join("; ", result.MissingItems), - string.Join("; ", result.Details)); - } - - // Persist the detected components on the cluster so we don't need to - // re-scan next time. The cluster aggregate stores a snapshot of each - // component's status and configuration as last seen. - - cluster.UpdateComponents(results.ToList()); - await repository.UpdateAsync(cluster, ct); - - // Build the aggregate report from all individual results. - - AdoptionReport report = AdoptionReport.FromChecks(results.ToList()); - - return Result.Success(report); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/AdoptionReport.cs b/src/EntKube.Clusters/Features/AdoptCluster/AdoptionReport.cs deleted file mode 100644 index 3949819..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/AdoptionReport.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.Json.Serialization; - -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// Aggregates all component check results into a single adoption report. -/// Each component has its own line item, and the overall status is derived -/// from the worst individual result — if any component is NotInstalled, -/// the cluster is NotReady; if some are Degraded, it's PartiallyReady. -/// -public class AdoptionReport -{ - [JsonConverter(typeof(JsonStringEnumConverter))] - public AdoptionReadiness Status { get; } - public List Components { get; } - - public AdoptionReport(AdoptionReadiness status, List components) - { - Status = status; - Components = components; - } - - /// - /// Builds the aggregate report from individual component results. - /// The overall readiness is the worst status across all components. - /// Components are always sorted alphabetically by name for consistent display. - /// - public static AdoptionReport FromChecks(List results) - { - // Sort components alphabetically by name so the report is always - // presented in a consistent, predictable order. - - List sorted = results - .OrderBy(r => r.ComponentName, StringComparer.OrdinalIgnoreCase) - .ToList(); - - // If any component is entirely missing, the cluster is not ready for adoption. - - if (sorted.Any(r => r.Status == ComponentStatus.NotInstalled)) - { - return new AdoptionReport(AdoptionReadiness.NotReady, sorted); - } - - // If all are installed but some are degraded (unhealthy or incomplete), - // the cluster is partially ready — it can operate but needs attention. - - if (sorted.Any(r => r.Status == ComponentStatus.Degraded)) - { - return new AdoptionReport(AdoptionReadiness.PartiallyReady, sorted); - } - - // Everything is present and healthy — cluster is ready for full adoption. - - return new AdoptionReport(AdoptionReadiness.Ready, sorted); - } -} - -/// -/// Overall cluster adoption readiness, derived from the aggregate of all -/// component checks: -/// - Ready: all components installed and healthy -/// - PartiallyReady: some components degraded but present -/// - NotReady: one or more components entirely missing -/// -public enum AdoptionReadiness -{ - Ready, - PartiallyReady, - NotReady -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/ComponentSchemas.cs b/src/EntKube.Clusters/Features/AdoptCluster/ComponentSchemas.cs deleted file mode 100644 index d203c97..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/ComponentSchemas.cs +++ /dev/null @@ -1,876 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// Central registry of configuration schemas for all managed components. -/// Each schema declares what parameters a component accepts, their types, -/// validation constraints, defaults, and UI groupings. The UI reads these -/// to render proper typed forms with validation instead of raw key/value editors. -/// -public static class ComponentSchemas -{ - private static readonly Dictionary schemas = new(StringComparer.OrdinalIgnoreCase) - { - ["kyverno"] = new( - ComponentName: "kyverno", - DisplayName: "Kyverno", - Description: "Policy engine for Kubernetes — validates, mutates, and generates resources", - Parameters: new List - { - new(Key: "admissionReplicas", DisplayName: "Admission Controller Replicas", Description: "Number of admission controller pod replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 10), - new(Key: "backgroundReplicas", DisplayName: "Background Controller Replicas", Description: "Number of background controller pod replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 10) - }), - - ["security-policies"] = new( - ComponentName: "security-policies", - DisplayName: "Security Policies", - Description: "Kyverno ClusterPolicies for security enforcement (image registries, load balancers, seccomp, privilege escalation)", - Parameters: new List - { - new(Key: "validationAction", DisplayName: "Validation Action", Description: "Whether policies audit or enforce violations", - Type: ParameterType.Select, Group: "Enforcement", DefaultValue: "Audit", IsRequired: true, - AllowedValues: new List { "Audit", "Enforce" }), - new(Key: "revertPatches", DisplayName: "Revert Seccomp & ReadOnly Patches", Description: "Deletes the seccomp and readOnlyRootFilesystem policies and reverts workload patches applied during adoption. Use this if workloads are failing due to these security constraints.", - Type: ParameterType.Boolean, Group: "Emergency", DefaultValue: "false") - }), - - ["cert-manager"] = new( - ComponentName: "cert-manager", - DisplayName: "cert-manager", - Description: "Certificate lifecycle management for Kubernetes — automates TLS certificate issuance and renewal", - Parameters: new List - { - new(Key: "replicas", DisplayName: "Controller Replicas", Description: "Number of cert-manager controller replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5) - }), - - ["trust-manager"] = new( - ComponentName: "trust-manager", - DisplayName: "trust-manager", - Description: "Distributes trust bundles (CA certificates) across namespaces", - Parameters: new List - { - new(Key: "bundleName", DisplayName: "Bundle Name", Description: "Name of the trust Bundle resource", - Type: ParameterType.String, Group: "General", DefaultValue: "entkube-trust-bundle"), - new(Key: "addCertificate", DisplayName: "Add Certificate", Description: "Add a new CA certificate to the trust bundle", - Type: ParameterType.Boolean, Group: "Certificates", DefaultValue: "false"), - new(Key: "certificateName", DisplayName: "Certificate Name", Description: "Name identifier for the certificate being added", - Type: ParameterType.String, Group: "Certificates"), - new(Key: "certificateData", DisplayName: "Certificate PEM Data", Description: "PEM-encoded CA certificate to add", - Type: ParameterType.String, Group: "Certificates"), - new(Key: "removeCertificate", DisplayName: "Remove Certificate", Description: "Name of certificate to remove from the bundle", - Type: ParameterType.String, Group: "Certificates") - }), - - ["internal-ca"] = new( - ComponentName: "internal-ca", - DisplayName: "Internal CA", - Description: "Self-signed Certificate Authority for internal service-to-service TLS", - Parameters: new List - { - new(Key: "caName", DisplayName: "CA Issuer Name", Description: "Name of the CA Issuer resource", - Type: ParameterType.String, Group: "General", DefaultValue: "internal-ca"), - new(Key: "bundleName", DisplayName: "Trust Bundle Name", Description: "Bundle to inject the CA certificate into", - Type: ParameterType.String, Group: "General", DefaultValue: "entkube-trust-bundle"), - new(Key: "caDurationDays", DisplayName: "CA Duration (days)", Description: "Lifetime of the CA certificate in days", - Type: ParameterType.Integer, Group: "Certificate", DefaultValue: "3650", Min: 30, Max: 36500), - new(Key: "caOrganization", DisplayName: "Organization", Description: "Organization name in the CA certificate subject", - Type: ParameterType.String, Group: "Certificate", DefaultValue: "EntKube") - }), - - ["domain-ca"] = new( - ComponentName: "domain-ca", - DisplayName: "Domain CA", - Description: "Certificate Authority for domain-scoped TLS (e.g., *.tenant.example.com)", - Parameters: new List - { - new(Key: "caName", DisplayName: "CA Issuer Name", Description: "Name of the domain CA Issuer resource", - Type: ParameterType.String, Group: "General", DefaultValue: "domain-ca"), - new(Key: "domains", DisplayName: "Domains", Description: "Comma-separated list of domains this CA can issue for", - Type: ParameterType.String, Group: "General", IsRequired: true), - new(Key: "bundleName", DisplayName: "Trust Bundle Name", Description: "Bundle to inject the CA certificate into", - Type: ParameterType.String, Group: "General", DefaultValue: "entkube-trust-bundle") - }), - - ["istio"] = new( - ComponentName: "istio", - DisplayName: "Istio", - Description: "Service mesh with mTLS, traffic management, and observability", - Parameters: new List - { - new(Key: "pilotReplicas", DisplayName: "Pilot Replicas", Description: "Number of istiod (pilot) replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 10), - new(Key: "enableGatewayApi", DisplayName: "Enable Gateway API", Description: "Enable Kubernetes Gateway API support in Istio", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true") - }), - - ["minio"] = new( - ComponentName: "minio", - DisplayName: "MinIO Operator", - Description: "Object storage operator — manages MinIO tenants for S3-compatible storage", - Parameters: new List - { - new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of MinIO Operator pod replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5) - }), - - ["mongodb-community"] = new( - ComponentName: "mongodb-community", - DisplayName: "MongoDB Community Operator", - Description: "MongoDB operator — manages MongoDB replica sets with SCRAM authentication, scaling, and upgrades", - Parameters: new List - { - new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of MongoDB operator pod replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5), - new(Key: "watchNamespace", DisplayName: "Watch Namespace", Description: "Namespace to watch for MongoDBCommunity resources (* = all namespaces)", - Type: ParameterType.String, Group: "General", DefaultValue: "*") - }), - - ["monitoring"] = new( - ComponentName: "monitoring", - DisplayName: "Monitoring (kube-prometheus-stack)", - Description: "Full observability stack — Prometheus, Alertmanager, Grafana, kube-state-metrics, node-exporter", - Parameters: new List - { - new(Key: "retention", DisplayName: "Retention Period", Description: "How long Prometheus keeps metric data (e.g., 30d, 90d)", - Type: ParameterType.String, Group: "Prometheus", DefaultValue: "30d", IsRequired: true), - new(Key: "retentionSize", DisplayName: "Retention Size", Description: "Maximum total size of stored metrics (e.g., 45GB)", - Type: ParameterType.String, Group: "Prometheus", DefaultValue: "45GB"), - new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus server replicas", - Type: ParameterType.Integer, Group: "Prometheus", DefaultValue: "2", Min: 1, Max: 10), - new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size for each Prometheus replica (e.g., 50Gi)", - Type: ParameterType.String, Group: "Prometheus", DefaultValue: "50Gi"), - new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana alongside Prometheus", - Type: ParameterType.Boolean, Group: "Grafana", DefaultValue: "true"), - new(Key: "alertmanagerEnabled", DisplayName: "Enable Alertmanager", Description: "Deploy Alertmanager for alert routing", - Type: ParameterType.Boolean, Group: "Alertmanager", DefaultValue: "true"), - new(Key: "alertmanagerReplicas", DisplayName: "Alertmanager Replicas", Description: "Number of Alertmanager replicas", - Type: ParameterType.Integer, Group: "Alertmanager", DefaultValue: "2", Min: 1, Max: 5), - - // ─── Ingress / Gateway API ─────────────────────────────── - new(Key: "ingressEnabled", DisplayName: "Enable Ingress", Description: "Publish monitoring services via ingress/Gateway API", - Type: ParameterType.Boolean, Group: "Ingress", DefaultValue: "false"), - new(Key: "ingressProvider", DisplayName: "Ingress Provider", Description: "How to expose monitoring services externally", - Type: ParameterType.Select, Group: "Ingress", DefaultValue: "gatewayapi", - AllowedValues: new List { "gatewayapi", "traefik", "istio" }, - DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }), - new(Key: "grafanaHostname", DisplayName: "Grafana Hostname", Description: "External hostname for Grafana (e.g., grafana.prod01.example.com)", - Type: ParameterType.String, Group: "Ingress", - DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }), - new(Key: "prometheusHostname", DisplayName: "Prometheus Hostname", Description: "External hostname for Prometheus (optional)", - Type: ParameterType.String, Group: "Ingress", - DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }), - new(Key: "alertmanagerHostname", DisplayName: "Alertmanager Hostname", Description: "External hostname for Alertmanager (optional)", - Type: ParameterType.String, Group: "Ingress", - DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }), - new(Key: "tlsEnabled", DisplayName: "TLS Enabled", Description: "Enable TLS for ingress routes", - Type: ParameterType.Boolean, Group: "Ingress", DefaultValue: "true", - DependsOnKey: "ingressEnabled", DependsOnValues: new List { "true" }), - new(Key: "tlsSecretName", DisplayName: "TLS Secret Name", Description: "Kubernetes Secret containing the TLS certificate", - Type: ParameterType.String, Group: "Ingress", - DependsOnKey: "tlsEnabled", DependsOnValues: new List { "true" }) - }), - - ["grafana"] = new( - ComponentName: "grafana", - DisplayName: "Grafana", - Description: "Visualization and dashboarding — deployed as part of the monitoring stack", - Parameters: new List - { - new(Key: "enabled", DisplayName: "Enabled", Description: "Whether Grafana is deployed", - Type: ParameterType.Boolean, Group: "General", DefaultValue: "true"), - new(Key: "persistence", DisplayName: "Persistence", Description: "Enable persistent storage for Grafana data", - Type: ParameterType.Boolean, Group: "Storage", DefaultValue: "false"), - new(Key: "persistenceSize", DisplayName: "Storage Size", Description: "PVC size for Grafana persistence (e.g., 10Gi)", - Type: ParameterType.String, Group: "Storage", DefaultValue: "10Gi"), - - // ─── OIDC Authentication ───────────────────────────────── - new(Key: "oidcEnabled", DisplayName: "Enable OIDC", Description: "Authenticate users via OpenID Connect (e.g., Entra ID, Keycloak)", - Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "false"), - new(Key: "oidcProvider", DisplayName: "OIDC Provider", Description: "OAuth provider type (generic_oauth for most IdPs)", - Type: ParameterType.Select, Group: "OIDC", DefaultValue: "generic_oauth", - AllowedValues: new List { "generic_oauth", "azuread", "github", "google" }, - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcClientId", DisplayName: "OIDC Client ID", Description: "Client ID from the identity provider", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcClientSecretRef", DisplayName: "Client Secret (Vault Reference)", Description: "Reference to client secret in the secrets service (e.g., vault://monitoring/grafana-oidc-secret)", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcAuthUrl", DisplayName: "Authorization URL", Description: "OIDC authorization endpoint", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcTokenUrl", DisplayName: "Token URL", Description: "OIDC token endpoint", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcApiUrl", DisplayName: "UserInfo URL", Description: "OIDC userinfo endpoint", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcScopes", DisplayName: "Scopes", Description: "OIDC scopes to request (e.g., openid profile email)", - Type: ParameterType.String, Group: "OIDC", DefaultValue: "openid profile email", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcRoleAttributePath", DisplayName: "Role Attribute Path", Description: "JMESPath expression to map IdP claims to Grafana roles", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcAutoLogin", DisplayName: "Auto Login", Description: "Skip the Grafana login form and redirect directly to the IdP", - Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "true", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }) - }), - - ["harbor"] = new( - ComponentName: "harbor", - DisplayName: "Harbor", - Description: "Container registry with vulnerability scanning, image signing, and proxy cache", - Parameters: new List - { - new(Key: "harborDomain", DisplayName: "Harbor Domain", Description: "Domain name for Harbor registry access", - Type: ParameterType.String, Group: "General", IsRequired: true), - new(Key: "cnpgClusterName", DisplayName: "CNPG Cluster", Description: "CloudNativePG cluster to create the Harbor database on. When set, Harbor uses external PostgreSQL instead of embedded", - Type: ParameterType.PostgresCluster, Group: "Database"), - new(Key: "cnpgNamespace", DisplayName: "CNPG Namespace", Description: "Namespace where the CNPG cluster is running", - Type: ParameterType.String, Group: "Database"), - new(Key: "storageBucketId", DisplayName: "Registry Storage Bucket", Description: "Use a pre-created S3 bucket for registry image storage instead of PVC", - Type: ParameterType.StorageBucket, Group: "Storage"), - new(Key: "adminPassword", DisplayName: "Admin Password", Description: "Harbor admin account password", - Type: ParameterType.String, Group: "General"), - new(Key: "wireAllServices", DisplayName: "Wire All Services", Description: "Automatically configure all namespaces to use Harbor as image source", - Type: ParameterType.Boolean, Group: "Integration", DefaultValue: "false"), - new(Key: "addProxyCache", DisplayName: "Add Proxy Cache", Description: "Upstream registry URL to create a proxy cache for (e.g., docker.io)", - Type: ParameterType.String, Group: "Proxy Cache"), - new(Key: "proxyCacheProjectName", DisplayName: "Proxy Cache Project", Description: "Harbor project name for the proxy cache", - Type: ParameterType.String, Group: "Proxy Cache"), - new(Key: "setDefaultCache", DisplayName: "Set Default Cache", Description: "Set proxy cache as default for docker.io pulls", - Type: ParameterType.Boolean, Group: "Proxy Cache", DefaultValue: "false"), - new(Key: "createProject", DisplayName: "Create Project", Description: "Name of a new Harbor project to create", - Type: ParameterType.String, Group: "Projects"), - new(Key: "projectVisibility", DisplayName: "Project Visibility", Description: "Visibility for newly created projects", - Type: ParameterType.Select, Group: "Projects", DefaultValue: "private", - AllowedValues: new List { "public", "private" }), - new(Key: "enableScanOnPush", DisplayName: "Scan on Push", Description: "Automatically scan images when pushed", - Type: ParameterType.Boolean, Group: "Security", DefaultValue: "true"), - new(Key: "severityThreshold", DisplayName: "Severity Threshold", Description: "Minimum vulnerability severity to block deployment", - Type: ParameterType.Select, Group: "Security", - AllowedValues: new List { "none", "low", "medium", "high", "critical" }), - new(Key: "scanSchedule", DisplayName: "Scan Schedule", Description: "Cron expression for periodic vulnerability scanning", - Type: ParameterType.String, Group: "Security", DefaultValue: "0 2 * * *"), - new(Key: "createPullSecret", DisplayName: "Create Pull Secret", Description: "Create an image pull secret for Harbor access", - Type: ParameterType.Boolean, Group: "Integration", DefaultValue: "false"), - new(Key: "targetNamespace", DisplayName: "Target Namespace", Description: "Namespace to create the pull secret in", - Type: ParameterType.String, Group: "Integration") - }), - - ["traefik"] = new( - ComponentName: "traefik", - DisplayName: "Traefik Ingress Controller", - Description: "High-performance ingress controller with automatic TLS, middleware support, and dashboard", - Parameters: new List - { - new(Key: "replicas", DisplayName: "Replicas", Description: "Number of Traefik ingress controller replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 20), - new(Key: "dashboardEnabled", DisplayName: "Dashboard", Description: "Expose the Traefik management dashboard", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "false"), - new(Key: "serviceType", DisplayName: "Service Type", Description: "Kubernetes Service type for the Traefik LoadBalancer", - Type: ParameterType.Select, Group: "Networking", DefaultValue: "LoadBalancer", - AllowedValues: new List { "LoadBalancer", "NodePort", "ClusterIP" }), - new(Key: "internalLoadBalancer", DisplayName: "Internal Load Balancer", Description: "Use cloud-provider internal LB annotations (private LB)", - Type: ParameterType.Boolean, Group: "Networking", DefaultValue: "false") - }), - - ["ingress"] = new( - ComponentName: "ingress", - DisplayName: "Ingress Gateway Infrastructure", - Description: "Gateway infrastructure layer — auto-detects Istio or Traefik and deploys the appropriate load balancer resources", - Parameters: new List - { - new(Key: "enable_external", DisplayName: "External Gateway", Description: "Deploy an external-facing gateway (Istio only — Traefik uses a single LB)", - Type: ParameterType.Boolean, Group: "General", DefaultValue: "false") - }), - - ["keycloak"] = new( - ComponentName: "keycloak", - DisplayName: "Keycloak", - Description: "Identity and access management — SSO, user federation, social login, OIDC/SAML", - Parameters: new List - { - new(Key: "keycloakDomain", DisplayName: "Domain", Description: "External domain for Keycloak access", - Type: ParameterType.String, Group: "General", IsRequired: true), - new(Key: "cnpgClusterName", DisplayName: "CNPG Cluster", Description: "CloudNativePG cluster to create the Keycloak database on", - Type: ParameterType.PostgresCluster, Group: "Database"), - new(Key: "cnpgNamespace", DisplayName: "CNPG Namespace", Description: "Namespace where the CNPG cluster is running", - Type: ParameterType.String, Group: "Database"), - new(Key: "createRealm", DisplayName: "Create Realm", Description: "Name of a new realm to create", - Type: ParameterType.String, Group: "Realms"), - new(Key: "realmDisplayName", DisplayName: "Realm Display Name", Description: "Human-readable name for the realm", - Type: ParameterType.String, Group: "Realms"), - new(Key: "addProvider", DisplayName: "Add Identity Provider", Description: "Type of identity provider to add", - Type: ParameterType.Select, Group: "Identity Providers", - AllowedValues: new List { "bankid", "oidc", "saml" }), - new(Key: "realmName", DisplayName: "Target Realm", Description: "Realm to add the identity provider to", - Type: ParameterType.String, Group: "Identity Providers"), - new(Key: "bankIdEnvironment", DisplayName: "BankID Environment", Description: "BankID environment (test or production)", - Type: ParameterType.Select, Group: "BankID", - AllowedValues: new List { "test", "production" }), - new(Key: "bankIdDisplayName", DisplayName: "BankID Display Name", Description: "Display name for BankID login button", - Type: ParameterType.String, Group: "BankID", DefaultValue: "BankID"), - new(Key: "bankIdCertificateP12", DisplayName: "BankID Certificate (Base64)", Description: "Base64-encoded PKCS#12 certificate", - Type: ParameterType.String, Group: "BankID"), - new(Key: "bankIdCertificatePassword", DisplayName: "Certificate Password", Description: "Password for the PKCS#12 certificate", - Type: ParameterType.String, Group: "BankID"), - new(Key: "oidcDiscoveryUrl", DisplayName: "OIDC Discovery URL", Description: "OpenID Connect discovery endpoint URL", - Type: ParameterType.String, Group: "OIDC"), - new(Key: "oidcClientId", DisplayName: "OIDC Client ID", Description: "Client ID for the OIDC provider", - Type: ParameterType.String, Group: "OIDC"), - new(Key: "oidcClientSecret", DisplayName: "OIDC Client Secret", Description: "Client secret for the OIDC provider", - Type: ParameterType.String, Group: "OIDC"), - new(Key: "samlEntityId", DisplayName: "SAML Entity ID", Description: "Entity ID of the SAML identity provider", - Type: ParameterType.String, Group: "SAML"), - new(Key: "samlSsoUrl", DisplayName: "SAML SSO URL", Description: "Single Sign-On URL of the SAML provider", - Type: ParameterType.String, Group: "SAML"), - new(Key: "samlCertificate", DisplayName: "SAML Certificate", Description: "PEM certificate for SAML signature verification", - Type: ParameterType.String, Group: "SAML"), - new(Key: "updateTheme", DisplayName: "Update Theme", Description: "Whether to apply theme customization", - Type: ParameterType.Boolean, Group: "Theming", DefaultValue: "false"), - new(Key: "themePrimaryColor", DisplayName: "Primary Color", Description: "Primary brand color (hex, e.g. #1a73e8)", - Type: ParameterType.String, Group: "Theming"), - new(Key: "themeSecondaryColor", DisplayName: "Secondary Color", Description: "Secondary brand color (hex)", - Type: ParameterType.String, Group: "Theming"), - new(Key: "themeLogo", DisplayName: "Logo URL", Description: "URL to the logo image", - Type: ParameterType.String, Group: "Theming"), - new(Key: "themeBackground", DisplayName: "Background URL", Description: "URL to the background image", - Type: ParameterType.String, Group: "Theming") - }), - - ["letsencrypt"] = new( - ComponentName: "letsencrypt", - DisplayName: "Let's Encrypt", - Description: "Automated public TLS certificates via Let's Encrypt ACME protocol", - Parameters: new List - { - new(Key: "email", DisplayName: "Registration Email", Description: "Email address for Let's Encrypt account registration", - Type: ParameterType.String, Group: "General", IsRequired: true), - new(Key: "httpSolverEnabled", DisplayName: "HTTP-01 Solver", Description: "Enable HTTP-01 challenge solver", - Type: ParameterType.Boolean, Group: "HTTP-01 Solver", DefaultValue: "true"), - new(Key: "httpSolverMode", DisplayName: "HTTP-01 Mode", Description: "How cert-manager serves HTTP-01 challenges: via traditional Ingress or Gateway API HTTPRoute", - Type: ParameterType.Select, Group: "HTTP-01 Solver", DefaultValue: "ingress", - AllowedValues: new List { "ingress", "gatewayHTTPRoute" }, - DependsOnKey: "httpSolverEnabled", DependsOnValues: new List { "true" }), - new(Key: "httpSolverIngressClass", DisplayName: "Ingress Class", Description: "Ingress class for HTTP-01 challenge pods", - Type: ParameterType.String, Group: "HTTP-01 Solver", DefaultValue: "traefik", - DependsOnKey: "httpSolverMode", DependsOnValues: new List { "ingress" }), - new(Key: "httpSolverGatewayName", DisplayName: "Gateway Name", Description: "Name of the Gateway resource for HTTP-01 challenges (e.g., 'internal')", - Type: ParameterType.String, Group: "HTTP-01 Solver", DefaultValue: "internal", - DependsOnKey: "httpSolverMode", DependsOnValues: new List { "gatewayHTTPRoute" }), - new(Key: "httpSolverGatewayNamespace", DisplayName: "Gateway Namespace", Description: "Namespace of the Gateway resource (e.g., 'internal-ingress')", - Type: ParameterType.String, Group: "HTTP-01 Solver", - DependsOnKey: "httpSolverMode", DependsOnValues: new List { "gatewayHTTPRoute" }), - new(Key: "dnsSolverEnabled", DisplayName: "DNS-01 Solver", Description: "Enable DNS-01 challenge solver for wildcard certificates and private networks", - Type: ParameterType.Boolean, Group: "DNS-01 Solver", DefaultValue: "false"), - new(Key: "dnsSolverProvider", DisplayName: "DNS Provider", Description: "Cloud DNS provider for DNS-01 challenges", - Type: ParameterType.Select, Group: "DNS-01 Solver", - AllowedValues: new List { "cloudflare", "route53", "azuredns", "clouddns" }, - DependsOnKey: "dnsSolverEnabled", DependsOnValues: new List { "true" }), - new(Key: "dnsZones", DisplayName: "DNS Zones", Description: "Comma-separated DNS zones scoped to the DNS-01 solver (e.g., example.com, internal.example.com)", - Type: ParameterType.String, Group: "DNS-01 Solver", - DependsOnKey: "dnsSolverEnabled", DependsOnValues: new List { "true" }), - - // ─── Cloudflare-specific fields ────────────────────────────── - new(Key: "dnsSolverSecretName", DisplayName: "API Token Secret", Description: "Kubernetes Secret containing the Cloudflare API token (key: api-token)", - Type: ParameterType.String, Group: "Cloudflare DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "cloudflare" }), - - // ─── Route53-specific fields ───────────────────────────────── - new(Key: "dnsSolverHostedZone", DisplayName: "Hosted Zone ID", Description: "AWS Route53 hosted zone ID (e.g., Z2ABCDEF123456)", - Type: ParameterType.String, Group: "AWS Route53", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "route53" }), - - // ─── Azure DNS-specific fields ─────────────────────────────── - new(Key: "dnsSolverClientId", DisplayName: "Client ID", Description: "Azure AD application (service principal) client ID for DNS zone access", - Type: ParameterType.String, Group: "Azure DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }), - new(Key: "dnsSolverSubscriptionId", DisplayName: "Subscription ID", Description: "Azure subscription containing the DNS zone", - Type: ParameterType.String, Group: "Azure DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }), - new(Key: "dnsSolverTenantId", DisplayName: "Tenant ID", Description: "Azure AD tenant ID for the service principal", - Type: ParameterType.String, Group: "Azure DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }), - new(Key: "dnsSolverResourceGroup", DisplayName: "Resource Group", Description: "Azure resource group containing the DNS zone", - Type: ParameterType.String, Group: "Azure DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }), - new(Key: "dnsSolverSecretNameAzure", DisplayName: "Client Secret Name", Description: "Kubernetes Secret containing the Azure service principal client secret (key: client-secret)", - Type: ParameterType.String, Group: "Azure DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "azuredns" }), - - // ─── Google Cloud DNS-specific fields ──────────────────────── - new(Key: "dnsSolverProject", DisplayName: "GCP Project", Description: "Google Cloud project ID containing the DNS zone", - Type: ParameterType.String, Group: "Google Cloud DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "clouddns" }), - new(Key: "dnsSolverSecretNameGcp", DisplayName: "Service Account Secret", Description: "Kubernetes Secret containing the GCP service account JSON key", - Type: ParameterType.String, Group: "Google Cloud DNS", - DependsOnKey: "dnsSolverProvider", DependsOnValues: new List { "clouddns" }), - - // ─── Management ────────────────────────────────────────────── - new(Key: "deleteStaging", DisplayName: "Delete Staging Issuer", Description: "Remove the staging ClusterIssuer", - Type: ParameterType.Boolean, Group: "Management", DefaultValue: "false"), - new(Key: "deleteProduction", DisplayName: "Delete Production Issuer", Description: "Remove the production ClusterIssuer", - Type: ParameterType.Boolean, Group: "Management", DefaultValue: "false") - }), - - ["network-policies"] = new( - ComponentName: "network-policies", - DisplayName: "Network Policies", - Description: "Namespace-scoped network segmentation — controls which pods can communicate", - Parameters: new List()), - - ["cnpg"] = new( - ComponentName: "cnpg", - DisplayName: "CloudNativePG", - Description: "PostgreSQL operator — manages highly available PostgreSQL clusters on Kubernetes", - Parameters: new List - { - new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of CNPG operator pod replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5), - new(Key: "monitoringEnabled", DisplayName: "Monitoring", Description: "Enable Prometheus metrics for the operator", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - new(Key: "walBucketId", DisplayName: "WAL/Backup Bucket", Description: "Use a pre-created S3 bucket for WAL archiving and base backups instead of MinIO", - Type: ParameterType.StorageBucket, Group: "Backup Storage") - }), - - ["redis"] = new( - ComponentName: "redis", - DisplayName: "Redis Operator", - Description: "In-memory data store operator — manages Redis clusters with Sentinel HA", - Parameters: new List - { - new(Key: "replicaCount", DisplayName: "Replica Count", Description: "Number of Redis replica pods", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "3", Min: 1, Max: 10), - new(Key: "sentinelEnabled", DisplayName: "Sentinel HA", Description: "Enable Redis Sentinel for automatic failover", - Type: ParameterType.Boolean, Group: "High Availability", DefaultValue: "true"), - new(Key: "sentinelReplicas", DisplayName: "Sentinel Replicas", Description: "Number of Sentinel pods", - Type: ParameterType.Integer, Group: "High Availability", DefaultValue: "3", Min: 1, Max: 5), - new(Key: "maxMemory", DisplayName: "Max Memory", Description: "Maximum memory per Redis instance (e.g., 256mb, 1gb)", - Type: ParameterType.String, Group: "Resources", DefaultValue: "256mb"), - new(Key: "maxMemoryPolicy", DisplayName: "Eviction Policy", Description: "What happens when memory limit is reached", - Type: ParameterType.Select, Group: "Resources", DefaultValue: "allkeys-lru", - AllowedValues: new List { "noeviction", "allkeys-lru", "volatile-lru", "allkeys-random", "volatile-random", "volatile-ttl" }), - new(Key: "persistence", DisplayName: "Persistence", Description: "Enable AOF persistence", - Type: ParameterType.Boolean, Group: "Storage", DefaultValue: "true"), - new(Key: "persistenceSize", DisplayName: "Persistence Size", Description: "PVC size for Redis persistence", - Type: ParameterType.String, Group: "Storage", DefaultValue: "8Gi") - }), - - ["external-secrets"] = new( - ComponentName: "external-secrets", - DisplayName: "External Secrets Operator", - Description: "Synchronizes secrets from external providers (AWS SSM, Azure KeyVault, GCP Secret Manager, Vault)", - Parameters: new List - { - new(Key: "replicas", DisplayName: "Operator Replicas", Description: "Number of ESO controller replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5), - new(Key: "webhookReplicas", DisplayName: "Webhook Replicas", Description: "Number of webhook server replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5), - new(Key: "certControllerReplicas", DisplayName: "Cert Controller Replicas", Description: "Number of cert-controller replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 1, Max: 5), - new(Key: "storeProvider", DisplayName: "Secret Store Provider", Description: "External secret provider type", - Type: ParameterType.Select, Group: "Secret Store", - AllowedValues: new List { "aws", "azure", "gcp", "vault" }), - new(Key: "storeEndpoint", DisplayName: "Store Endpoint", Description: "Provider endpoint URL (Vault server URL, etc.)", - Type: ParameterType.String, Group: "Secret Store"), - new(Key: "storeSecretName", DisplayName: "Auth Secret", Description: "Kubernetes secret with provider credentials", - Type: ParameterType.String, Group: "Secret Store"), - new(Key: "storeRegion", DisplayName: "Region", Description: "Cloud region for the secret store (AWS/Azure/GCP)", - Type: ParameterType.String, Group: "Secret Store") - }), - - ["rabbitmq"] = new( - ComponentName: "rabbitmq", - DisplayName: "RabbitMQ Cluster Operator", - Description: "Message broker operator — manages RabbitMQ clusters with topology management", - Parameters: new List - { - new(Key: "operatorReplicas", DisplayName: "Operator Replicas", Description: "Number of RabbitMQ operator pod replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5), - new(Key: "topologyOperator", DisplayName: "Topology Operator", Description: "Deploy the topology operator for declarative queue/exchange management", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - new(Key: "monitoringEnabled", DisplayName: "Monitoring", Description: "Enable Prometheus ServiceMonitor for the operator", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true") - }), - - ["custom-dns"] = new( - ComponentName: "custom-dns", - DisplayName: "Custom DNS", - Description: "ExternalDNS controller — automatically manages DNS records from Kubernetes resources", - Parameters: new List - { - new(Key: "provider", DisplayName: "DNS Provider", Description: "Cloud DNS provider", - Type: ParameterType.Select, Group: "General", IsRequired: true, - AllowedValues: new List { "cloudflare", "route53", "azure", "google", "digitalocean" }), - new(Key: "domainFilter", DisplayName: "Domain Filter", Description: "Only manage records in these domains (comma-separated)", - Type: ParameterType.String, Group: "General"), - new(Key: "txtOwnerId", DisplayName: "TXT Owner ID", Description: "Unique identifier for this ExternalDNS instance", - Type: ParameterType.String, Group: "General", DefaultValue: "entkube"), - new(Key: "policy", DisplayName: "Record Policy", Description: "How to handle existing DNS records", - Type: ParameterType.Select, Group: "General", DefaultValue: "upsert-only", - AllowedValues: new List { "upsert-only", "sync" }), - new(Key: "secretName", DisplayName: "Credentials Secret", Description: "Kubernetes secret with DNS provider credentials", - Type: ParameterType.String, Group: "Authentication") - }), - - ["gitea"] = new( - ComponentName: "gitea", - DisplayName: "Gitea", - Description: "Self-hosted Git service with built-in CI/CD (Gitea Actions), package registry, and container registry", - Parameters: new List - { - // — Version — - new(Key: "version", DisplayName: "Gitea Version", Description: "Helm chart version to install or upgrade to", - Type: ParameterType.String, Group: "Version", DefaultValue: "10.6.0"), - - // — Scaling — - new(Key: "replicas", DisplayName: "Replicas", Description: "Number of Gitea application replicas", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "1", Min: 1, Max: 5), - new(Key: "runnerReplicas", DisplayName: "Runner Replicas", Description: "Number of Gitea Actions runner pods", - Type: ParameterType.Integer, Group: "Scaling", DefaultValue: "2", Min: 0, Max: 20), - new(Key: "runnerStorage", DisplayName: "Runner Storage", Description: "PVC size for each runner pod cache", - Type: ParameterType.String, Group: "Scaling", DefaultValue: "2Gi"), - - // — Features — - new(Key: "actionsEnabled", DisplayName: "Gitea Actions", Description: "Enable built-in CI/CD with Gitea Actions", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - new(Key: "projectsEnabled", DisplayName: "Projects", Description: "Enable project boards for issue/task management", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - new(Key: "sshEnabled", DisplayName: "SSH Access", Description: "Enable Git over SSH", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - new(Key: "sshPort", DisplayName: "SSH Port", Description: "SSH service port number", - Type: ParameterType.Integer, Group: "Features", DefaultValue: "22", Min: 1, Max: 65535, - DependsOnKey: "sshEnabled", DependsOnValues: new List { "true" }), - new(Key: "metricsEnabled", DisplayName: "Metrics", Description: "Enable Prometheus metrics endpoint and ServiceMonitor", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - new(Key: "disableRegistration", DisplayName: "Disable Registration", Description: "Prevent self-registration (use OIDC instead)", - Type: ParameterType.Boolean, Group: "Features", DefaultValue: "true"), - - // — Storage — - new(Key: "persistenceSize", DisplayName: "Storage Size", Description: "PVC size for Git repository storage", - Type: ParameterType.String, Group: "Storage", DefaultValue: "50Gi"), - - // — Ingress (selectors, not textboxes) — - new(Key: "ingressHost", DisplayName: "Ingress Host", Description: "Hostname for Gitea web UI (leave empty to skip ingress)", - Type: ParameterType.String, Group: "Ingress"), - new(Key: "ingressProvider", DisplayName: "Ingress Provider", Description: "Ingress controller type", - Type: ParameterType.Select, Group: "Ingress", DefaultValue: "traefik", - AllowedValues: new List { "traefik", "istio", "gatewayapi" }), - - // — Database (CNPG cluster selector — hides manual fields when selected) — - new(Key: "cnpgClusterName", DisplayName: "CNPG Cluster", Description: "CloudNativePG cluster to create the Gitea database on. Credentials are auto-generated and stored in the secrets vault", - Type: ParameterType.PostgresCluster, Group: "Database"), - new(Key: "cnpgNamespace", DisplayName: "CNPG Namespace", Description: "Namespace where the CNPG cluster is running (auto-filled when CNPG cluster is selected)", - Type: ParameterType.String, Group: "Database"), - new(Key: "dbName", DisplayName: "Database Name", Description: "PostgreSQL database name to create on the CNPG cluster", - Type: ParameterType.String, Group: "Database", DefaultValue: "gitea"), - - // — Redis (cluster selector) — - new(Key: "redisClusterName", DisplayName: "Redis Cluster", Description: "Redis cluster for cache, sessions, and queues. Falls back to in-memory if not selected", - Type: ParameterType.RedisCluster, Group: "Cache"), - - // — OIDC — - new(Key: "oidcEnabled", DisplayName: "OIDC Authentication", Description: "Enable OpenID Connect authentication (e.g. via Keycloak)", - Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "false"), - new(Key: "oidcProviderName", DisplayName: "Provider Name", Description: "Display name shown on the login button (e.g. 'Keycloak', 'Azure AD')", - Type: ParameterType.String, Group: "OIDC", DefaultValue: "Keycloak", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcDiscoveryUrl", DisplayName: "Discovery URL", Description: "OpenID Connect discovery endpoint (e.g. https://keycloak.example.com/realms/myrealm)", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcClientId", DisplayName: "Client ID", Description: "OIDC client ID registered in the identity provider", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcClientSecret", DisplayName: "Client Secret", Description: "OIDC client secret", - Type: ParameterType.String, Group: "OIDC", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcScopes", DisplayName: "Scopes", Description: "OIDC scopes to request", - Type: ParameterType.String, Group: "OIDC", DefaultValue: "openid profile email", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - new(Key: "oidcAutoDiscoverUrl", DisplayName: "Auto Discovery", Description: "Use the discovery URL to auto-configure endpoints", - Type: ParameterType.Boolean, Group: "OIDC", DefaultValue: "true", - DependsOnKey: "oidcEnabled", DependsOnValues: new List { "true" }), - - // — Integration (Harbor auto-detect) — - new(Key: "harborRegistryUrl", DisplayName: "Harbor Registry URL", Description: "Harbor registry URL for act-runner Docker mirror. Auto-detected from installed Harbor if available", - Type: ParameterType.String, Group: "Integration"), - - // — Object Storage (cascading — selecting a bucket hides manual S3/Azure fields) — - new(Key: "objectStorageType", DisplayName: "Object Storage", Description: "How Gitea stores LFS objects, packages, and attachments", - Type: ParameterType.Select, Group: "Object Storage", DefaultValue: "none", - AllowedValues: new List { "none", "bucket", "s3", "azure" }), - new(Key: "storageBucketId", DisplayName: "Storage Bucket", Description: "Use a pre-created storage bucket from the Storage tab", - Type: ParameterType.StorageBucket, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "bucket" }), - new(Key: "s3Endpoint", DisplayName: "S3 Endpoint", Description: "S3-compatible endpoint for object storage", - Type: ParameterType.String, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }), - new(Key: "s3AccessKey", DisplayName: "S3 Access Key", Description: "S3 access key ID", - Type: ParameterType.String, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }), - new(Key: "s3SecretKey", DisplayName: "S3 Secret Key", Description: "S3 secret access key", - Type: ParameterType.String, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }), - new(Key: "s3Bucket", DisplayName: "S3 Bucket", Description: "S3 bucket name for Gitea storage", - Type: ParameterType.String, Group: "Object Storage", DefaultValue: "gitea", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }), - new(Key: "s3Region", DisplayName: "S3 Region", Description: "S3 region", - Type: ParameterType.String, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "s3" }), - new(Key: "azureAccountName", DisplayName: "Azure Account Name", Description: "Azure Storage account name", - Type: ParameterType.String, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "azure" }), - new(Key: "azureAccountKey", DisplayName: "Azure Account Key", Description: "Azure Storage account key", - Type: ParameterType.String, Group: "Object Storage", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "azure" }), - new(Key: "azureContainer", DisplayName: "Azure Container", Description: "Azure Blob container name", - Type: ParameterType.String, Group: "Object Storage", DefaultValue: "gitea", - DependsOnKey: "objectStorageType", DependsOnValues: new List { "azure" }), - - // — Administration — - new(Key: "adminUsername", DisplayName: "Admin Username", Description: "Initial admin user (only used on first install)", - Type: ParameterType.String, Group: "Administration", DefaultValue: "gitea_admin"), - new(Key: "adminEmail", DisplayName: "Admin Email", Description: "Admin user email address", - Type: ParameterType.String, Group: "Administration") - }) - }; - - /// - /// Retrieves the configuration schema for a component by name. Returns an - /// empty schema if the component has no registered parameters. - /// - public static ConfigurationSchema GetSchema(string componentName) - { - if (schemas.TryGetValue(componentName, out ConfigurationSchema? schema)) - { - return schema; - } - - return new ConfigurationSchema( - ComponentName: componentName, - DisplayName: componentName, - Description: $"No configuration schema defined for '{componentName}'", - Parameters: new List()); - } - - /// - /// Returns all registered schemas for use in API responses. - /// - public static IReadOnlyDictionary GetAll() => schemas; - - /// - /// Returns a schema enriched with cluster-contextual defaults. Uses the - /// cluster's discovered component state (from adoption scans) to adjust - /// defaults so the configuration form pre-selects the right options. - /// - /// For Let's Encrypt: - /// - Detects the ingress provider (Istio vs Traefik) from the ingress component - /// - Sets httpSolverMode default to "gatewayHTTPRoute" when Istio is detected - /// - Sets httpSolverMode default to "ingress" when Traefik is detected - /// - Pre-fills gateway name/namespace from discovered gateways - /// - Converts gateway name to a Select with discovered options when available - /// - public static ConfigurationSchema EnrichForCluster( - string componentName, IReadOnlyList clusterComponents) - { - ConfigurationSchema baseSchema = GetSchema(componentName); - - if (componentName.Equals("ingress", StringComparison.OrdinalIgnoreCase)) - { - return EnrichIngressSchema(baseSchema, clusterComponents); - } - - if (componentName.Equals("letsencrypt", StringComparison.OrdinalIgnoreCase)) - { - return EnrichLetsEncryptSchema(baseSchema, clusterComponents); - } - - return baseSchema; - } - - /// - /// Enriches the ingress schema based on the cluster's discovered state. - /// When the cluster scan found an external gateway, the enable_external - /// parameter defaults to true so the toggle reflects reality. - /// - private static ConfigurationSchema EnrichIngressSchema( - ConfigurationSchema baseSchema, IReadOnlyList clusterComponents) - { - // Look up the ingress component's discovered configuration to see - // whether an external gateway was found during the cluster scan. - - ClusterComponent? ingressComponent = clusterComponents.FirstOrDefault( - c => c.ComponentName.Equals("ingress", StringComparison.OrdinalIgnoreCase)); - - bool hasExternalGateway = ingressComponent?.Configuration is not null - && ingressComponent.Configuration.TryGetValue("hasExternalGateway", out string? extGw) - && extGw.Equals("True", StringComparison.OrdinalIgnoreCase); - - if (!hasExternalGateway) - { - return baseSchema; - } - - // The cluster already has an external gateway — flip the default so - // the UI toggle shows "Enabled" instead of contradicting the current state. - - List enrichedParams = baseSchema.Parameters - .Select(p => p.Key == "enable_external" ? p with { DefaultValue = "true" } : p) - .ToList(); - - return baseSchema with { Parameters = enrichedParams }; - } - - /// - /// Enriches the Let's Encrypt schema based on the cluster's ingress provider - /// and discovered gateways. When Istio is detected, the HTTP-01 solver form - /// defaults to Gateway API mode. When Traefik is detected, it defaults to - /// ingress mode with the traefik class pre-selected. - /// - private static ConfigurationSchema EnrichLetsEncryptSchema( - ConfigurationSchema baseSchema, IReadOnlyList clusterComponents) - { - // Find the ingress component to determine the provider type. - - ClusterComponent? ingressComponent = clusterComponents.FirstOrDefault( - c => c.ComponentName.Equals("ingress", StringComparison.OrdinalIgnoreCase)); - - string? ingressProvider = null; - bool hasInternalGateway = false; - bool hasExternalGateway = false; - - if (ingressComponent?.Configuration is not null) - { - ingressComponent.Configuration.TryGetValue("provider", out ingressProvider); - - hasInternalGateway = ingressComponent.Configuration.TryGetValue("hasInternalGateway", out string? intGw) - && intGw.Equals("True", StringComparison.OrdinalIgnoreCase); - - hasExternalGateway = ingressComponent.Configuration.TryGetValue("hasExternalGateway", out string? extGw) - && extGw.Equals("True", StringComparison.OrdinalIgnoreCase); - } - - // Find discovered gateways from the letsencrypt component's config. - - ClusterComponent? letsEncryptComponent = clusterComponents.FirstOrDefault( - c => c.ComponentName.Equals("letsencrypt", StringComparison.OrdinalIgnoreCase)); - - List discoveredGateways = ParseDiscoveredGateways(letsEncryptComponent); - - // If no gateways from the letsencrypt check, build from ingress info. - - if (discoveredGateways.Count == 0) - { - if (hasInternalGateway) - { - discoveredGateways.Add(new DiscoveredGateway("internal", "internal-ingress")); - } - - if (hasExternalGateway) - { - discoveredGateways.Add(new DiscoveredGateway("external", "external-ingress")); - } - } - - // Build enriched parameters with adjusted defaults. - - bool isIstio = string.Equals(ingressProvider, "istio", StringComparison.OrdinalIgnoreCase); - bool isTraefik = string.Equals(ingressProvider, "traefik", StringComparison.OrdinalIgnoreCase); - - List enrichedParams = baseSchema.Parameters - .Select(p => EnrichLetsEncryptParameter(p, isIstio, isTraefik, discoveredGateways)) - .ToList(); - - return baseSchema with { Parameters = enrichedParams }; - } - - /// - /// Adjusts a single Let's Encrypt parameter based on the detected provider. - /// Sets appropriate defaults for Istio (gateway mode) or Traefik (ingress mode) - /// and converts gateway name to a Select when gateways are discovered. - /// - private static ConfigurationParameter EnrichLetsEncryptParameter( - ConfigurationParameter param, bool isIstio, bool isTraefik, - List discoveredGateways) - { - return param.Key switch - { - // When Istio is detected, default to Gateway API mode. - - "httpSolverMode" when isIstio => param with { DefaultValue = "gatewayHTTPRoute" }, - - // When Istio and gateways are discovered, show them as Select options. - - "httpSolverGatewayName" when isIstio && discoveredGateways.Count > 0 => - param with - { - Type = ParameterType.Select, - DefaultValue = discoveredGateways[0].Name, - AllowedValues = discoveredGateways.Select(g => g.Name).ToList() - }, - - // Default the gateway namespace to the first discovered gateway's namespace. - - "httpSolverGatewayNamespace" when isIstio && discoveredGateways.Count > 0 => - param with { DefaultValue = discoveredGateways[0].Namespace }, - - // When Traefik, ensure ingress class defaults to "traefik". - - "httpSolverIngressClass" when isTraefik => param with { DefaultValue = "traefik" }, - - _ => param - }; - } - - /// - /// Parses the availableGateways JSON from the letsencrypt component's - /// discovered configuration. The JSON is an array of {name, namespace} objects - /// stored by the LetsEncryptCheck during gateway discovery. - /// - private static List ParseDiscoveredGateways(ClusterComponent? component) - { - List gateways = new(); - - if (component?.Configuration is null) - { - return gateways; - } - - if (!component.Configuration.TryGetValue("availableGateways", out string? json) - || string.IsNullOrEmpty(json)) - { - return gateways; - } - - try - { - using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); - - foreach (System.Text.Json.JsonElement element in doc.RootElement.EnumerateArray()) - { - string? name = element.TryGetProperty("name", out System.Text.Json.JsonElement nameEl) - ? nameEl.GetString() : null; - string? ns = element.TryGetProperty("namespace", out System.Text.Json.JsonElement nsEl) - ? nsEl.GetString() : null; - - if (!string.IsNullOrEmpty(name)) - { - gateways.Add(new DiscoveredGateway(name, ns ?? "default")); - } - } - } - catch - { - // Malformed JSON — ignore and return empty list. - } - - return gateways; - } - - private record DiscoveredGateway(string Name, string Namespace); -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerCheck.cs deleted file mode 100644 index 882c479..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerCheck.cs +++ /dev/null @@ -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; - -/// -/// Checks whether cert-manager is installed and healthy on the cluster. -/// Cert-manager is a prerequisite for internal CA management, TLS certificate -/// issuance, and trust bundle distribution. The Terraform bootstrap module -/// deploys it as one of the first components. -/// -public class CertManagerCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "cert-manager"; - public string DisplayName => "cert-manager (Certificate Management)"; - - public CertManagerCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Look for cert-manager pods in the standard namespace. - - List runningPods = await FindCertManagerPods(client, ct); - - if (runningPods.Count == 0) - { - missing.Add("No cert-manager pods found in cert-manager namespace"); - return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing); - } - - details.AddRange(runningPods.Select(p => $"Pod running: {p}")); - - // Check for the key components: controller, webhook, cainjector. - - bool hasController = runningPods.Any(p => p.Contains("cert-manager") && !p.Contains("webhook") && !p.Contains("cainjector")); - bool hasWebhook = runningPods.Any(p => p.Contains("webhook")); - bool hasCainjector = runningPods.Any(p => p.Contains("cainjector")); - - if (!hasController) - { - missing.Add("cert-manager controller pod not found"); - } - - if (!hasWebhook) - { - missing.Add("cert-manager webhook pod not found"); - } - - if (!hasCainjector) - { - missing.Add("cert-manager cainjector pod not found"); - } - - // Also check if the Certificate CRD exists (confirms CRDs are installed). - - bool crdExists = await CertificateCrdExists(client, ct); - - if (!crdExists) - { - missing.Add("Certificate CRD (certificates.cert-manager.io) not found"); - } - else - { - details.Add("Certificate CRD present"); - } - - // Build discovered configuration from what we found on the cluster. - - string? version = await GetVersionFromPods(client, ct); - - DiscoveredConfiguration config = new( - Version: version, - Namespace: "cert-manager", - HelmReleaseName: "cert-manager", - Values: new Dictionary - { - ["controllerReplicas"] = runningPods.Count(p => p.Contains("cert-manager") && !p.Contains("webhook") && !p.Contains("cainjector")).ToString(), - ["hasWebhook"] = hasWebhook.ToString(), - ["hasCainjector"] = hasCainjector.ToString() - }); - - if (missing.Count > 0) - { - return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config); - } - - return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config); - } - - private async Task GetVersionFromPods(Kubernetes client, CancellationToken ct) - { - try - { - // List all pods in the cert-manager namespace — the namespace is - // dedicated to cert-manager, so no label filtering needed. This - // catches all components (controller, webhook, cainjector) regardless - // of Helm release name or label configuration. - - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - "cert-manager", - cancellationToken: ct); - - foreach (V1Pod pod in podList.Items) - { - if (pod.Spec?.Containers?.Count > 0) - { - string? image = pod.Spec.Containers[0].Image; - - if (image?.Contains(':') == true) - { - return image.Split(':').Last().TrimStart('v'); - } - } - } - } - catch { } - - return null; - } - - private async Task> FindCertManagerPods(Kubernetes client, CancellationToken ct) - { - List pods = new(); - - try - { - // List all running pods in the cert-manager namespace. Since this - // namespace is dedicated to cert-manager, we don't need to filter - // by labels — any running pod here is a cert-manager component. - - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - "cert-manager", - cancellationToken: ct); - - foreach (V1Pod pod in podList.Items) - { - if (pod.Status?.Phase == "Running") - { - pods.Add(pod.Metadata.Name); - } - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogDebug("cert-manager namespace not found on cluster"); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error checking for cert-manager pods"); - } - - return pods; - } - - private async Task CertificateCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "certificates.cert-manager.io", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerInstaller.cs deleted file mode 100644 index 260d7e0..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CertManager/CertManagerInstaller.cs +++ /dev/null @@ -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; - -/// -/// Installs cert-manager via Helm. cert-manager provides the CRD runtime for -/// Certificate, Issuer, and ClusterIssuer resources. Let's Encrypt ClusterIssuer -/// configuration is handled separately by the LetsEncryptInstaller. -/// -/// What gets deployed: -/// 1. cert-manager namespace with controller, webhook, and cainjector -/// 2. CRDs for Certificate, Issuer, ClusterIssuer, etc. -/// -/// Configuration keys: -/// - "replicas": cert-manager controller replicas (default: 1) -/// -public class CertManagerInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string DefaultVersion = "1.16.3"; - private const string DefaultNamespace = "cert-manager"; - private const string HelmRepo = "https://charts.jetstack.io"; - - public string ComponentName => "cert-manager"; - - public CertManagerInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - // Ensure the namespace exists. - - Kubernetes client = BuildClient(cluster); - await EnsureNamespace(client, targetNamespace, ct); - actions.Add($"Ensured namespace '{targetNamespace}'"); - - // Install cert-manager with CRDs via Helm. - - await RunCommand("helm", - $"upgrade --install cert-manager cert-manager " + - $"--repo {HelmRepo} --version v{version} " + - $"--namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--set crds.enabled=true " + - $"--set replicaCount=1 " + - $"--set webhook.replicaCount=1 " + - $"--set cainjector.replicaCount=1 " + - $"--set resources.requests.cpu=50m " + - $"--set resources.requests.memory=128Mi " + - $"--set resources.limits.cpu=200m " + - $"--set resources.limits.memory=256Mi " + - $"--set prometheus.enabled=true " + - $"--set prometheus.servicemonitor.enabled=false " + - $"--wait --timeout 10m", - ct); - actions.Add($"Helm install cert-manager v{version} with CRDs"); - - logger.LogInformation("cert-manager {Version} installed on cluster {Cluster}", - version, cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"cert-manager {version} installed", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to install cert-manager on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to install cert-manager: {ex.Message}", - Actions: actions); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Reconfigures cert-manager. Supports: - /// - "replicas": controller replicas - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - // Handle Helm-level configuration (replicas, resources). - - List setFlags = new(); - - if (configuration.Values.TryGetValue("replicas", out string? replicas)) - { - setFlags.Add($"--set replicaCount={replicas}"); - } - - if (setFlags.Count > 0) - { - await RunCommand("helm", - $"upgrade cert-manager cert-manager --repo {HelmRepo} " + - $"--namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m", - ct); - actions.Add($"Updated cert-manager Helm release: {string.Join(", ", setFlags)}"); - } - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "cert-manager reconfigured", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to reconfigure cert-manager on cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to reconfigure cert-manager: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls cert-manager from the cluster by removing the Helm release. - /// Warning: this removes all CRDs too — any Certificate, Issuer, and - /// ClusterIssuer resources on the cluster will be deleted. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-certmgr-rm-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - await RunCommand("helm", - $"uninstall cert-manager --namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--wait --timeout 5m", - ct); - actions.Add("Helm uninstall cert-manager"); - - logger.LogInformation("cert-manager uninstalled from cluster {Cluster}", cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "cert-manager uninstalled successfully", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to uninstall cert-manager from cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to uninstall cert-manager: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - private async Task EnsureNamespace(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGCheck.cs deleted file mode 100644 index 36793a1..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGCheck.cs +++ /dev/null @@ -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; - -/// -/// Checks whether CloudNativePG (CNPG) operator is installed and healthy on the cluster. -/// CNPG is the Kubernetes operator for managing PostgreSQL databases natively. -/// It handles provisioning, failover, backup, and point-in-time recovery. -/// -/// This check verifies: -/// 1. CNPG operator pods running in cnpg-system namespace -/// 2. Cluster CRD (clusters.postgresql.cnpg.io) is registered -/// 3. Any existing PostgreSQL clusters managed by the operator -/// -public class CloudNativePGCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "cnpg"; - public string DisplayName => "CloudNativePG (PostgreSQL Operator)"; - - public CloudNativePGCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: CNPG operator pods in cnpg-system namespace. - - List operatorPods = await FindOperatorPods(client, ct); - - if (operatorPods.Count > 0) - { - details.AddRange(operatorPods.Select(p => $"Operator pod: {p}")); - } - else - { - missing.Add("No CNPG operator pods found in cnpg-system namespace"); - return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing); - } - - // Check 2: Cluster CRD exists (confirms operator CRDs installed). - - bool crdExists = await ClusterCrdExists(client, ct); - - if (crdExists) - { - details.Add("Cluster CRD (clusters.postgresql.cnpg.io) present"); - } - else - { - missing.Add("Cluster CRD (clusters.postgresql.cnpg.io) not found"); - } - - // Check 3: Discover existing PostgreSQL clusters across all namespaces. - - int clusterCount = await CountPostgresClusters(client, ct); - details.Add($"Managed PostgreSQL clusters: {clusterCount}"); - - // Discover operator version from the controller pod image tag. - - string? version = await GetVersionFromPods(client, ct); - - DiscoveredConfiguration config = new( - Version: version, - Namespace: "cnpg-system", - HelmReleaseName: "cnpg", - Values: new Dictionary - { - ["operatorReplicas"] = operatorPods.Count.ToString(), - ["managedClusters"] = clusterCount.ToString(), - ["crdInstalled"] = crdExists.ToString() - }); - - if (missing.Count > 0) - { - return new ComponentCheckResult(ComponentName, ComponentStatus.Degraded, details, missing, config); - } - - return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config); - } - - private async Task GetVersionFromPods(Kubernetes client, CancellationToken ct) - { - try - { - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - "cnpg-system", - labelSelector: "app.kubernetes.io/name=cloudnative-pg", - cancellationToken: ct); - - foreach (V1Pod pod in podList.Items) - { - if (pod.Spec?.Containers?.Count > 0) - { - string? image = pod.Spec.Containers[0].Image; - - if (image?.Contains(':') == true) - { - return image.Split(':').Last().TrimStart('v'); - } - } - } - } - catch { } - - return null; - } - - private async Task> FindOperatorPods(Kubernetes client, CancellationToken ct) - { - List pods = new(); - - try - { - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - "cnpg-system", - labelSelector: "app.kubernetes.io/name=cloudnative-pg", - cancellationToken: ct); - - foreach (V1Pod pod in podList.Items) - { - if (pod.Status?.Phase == "Running") - { - pods.Add(pod.Metadata.Name); - } - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogDebug("cnpg-system namespace not found on cluster"); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error checking for CNPG operator pods"); - } - - return pods; - } - - private async Task ClusterCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "clusters.postgresql.cnpg.io", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private async Task CountPostgresClusters(Kubernetes client, CancellationToken ct) - { - try - { - object result = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "postgresql.cnpg.io", - version: "v1", - plural: "clusters", - cancellationToken: ct); - - // Serialize then parse — the k8s client can return different runtime - // types (JsonElement, JObject, Dictionary) depending on configuration. - - string json = System.Text.Json.JsonSerializer.Serialize(result); - - using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); - - if (doc.RootElement.TryGetProperty("items", out System.Text.Json.JsonElement items)) - { - return items.GetArrayLength(); - } - - return 0; - } - catch - { - return 0; - } - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGInstaller.cs deleted file mode 100644 index d2b8099..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CloudNativePG/CloudNativePGInstaller.cs +++ /dev/null @@ -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; - -/// -/// Installs CloudNativePG (CNPG) operator via Helm. The operator manages the full -/// lifecycle of PostgreSQL databases on Kubernetes — provisioning, HA failover, -/// automated backups, point-in-time recovery, and rolling updates. -/// -/// What gets deployed: -/// 1. cnpg-system namespace with the CNPG operator controller -/// 2. All required CRDs (Cluster, Backup, ScheduledBackup, Pooler, etc.) -/// 3. Webhook configuration for validating/mutating PostgreSQL clusters -/// -/// Configuration keys: -/// - "operatorReplicas": Number of operator controller replicas (default: 1) -/// - "monitoringEnabled": Whether to create ServiceMonitor for Prometheus scraping -/// -/// Note: This installs the OPERATOR only. Actual PostgreSQL clusters are provisioned -/// separately via the Provisioning service (per-tenant databases). -/// -public class CloudNativePGInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string DefaultVersion = "0.22.0"; - private const string DefaultNamespace = "cnpg-system"; - private const string HelmRepo = "https://cloudnative-pg.github.io/charts"; - - public string ComponentName => "cnpg"; - - public CloudNativePGInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - bool monitoringEnabled = options.Parameters?.TryGetValue("monitoringEnabled", out string? monVal) == true - && monVal == "true"; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - // Ensure the namespace exists with management labels. - - Kubernetes client = BuildClient(cluster); - await EnsureNamespaceWithLabels(client, targetNamespace, ct); - actions.Add($"Ensured namespace '{targetNamespace}' with management labels"); - - // Install CNPG operator via Helm. The chart includes all CRDs, - // the controller deployment, webhook configuration, and RBAC. - - string monitoringFlag = monitoringEnabled - ? "--set monitoring.podMonitorEnabled=true --set monitoring.grafanaDashboard.create=true" - : "--set monitoring.podMonitorEnabled=false"; - - await RunCommand("helm", - $"upgrade --install cnpg cloudnative-pg " + - $"--repo {HelmRepo} --version {version} " + - $"--namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--set replicaCount=1 " + - $"--set resources.requests.cpu=100m " + - $"--set resources.requests.memory=256Mi " + - $"--set resources.limits.cpu=500m " + - $"--set resources.limits.memory=512Mi " + - $"{monitoringFlag} " + - $"--wait --timeout 10m", - ct); - actions.Add($"Helm install cnpg operator v{version}"); - - // If the user selected a storage bucket for WAL/backup, create a - // Kubernetes secret with the bucket credentials so CNPG clusters can - // reference it for Barman object store (S3-based WAL archiving). - - if (options.Parameters?.TryGetValue("walBucketId", out string? walBucketIdVal) == true - && !string.IsNullOrEmpty(walBucketIdVal) && Guid.TryParse(walBucketIdVal, out Guid walBucketId)) - { - StorageBucket? walBucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == walBucketId); - - if (walBucket is not null) - { - await EnsureBackupStorageSecret(client, targetNamespace, walBucket, ct); - actions.Add($"Created backup storage secret 'cnpg-backup-creds' with bucket '{walBucket.Name}'"); - actions.Add($"CNPG clusters can use: endpointURL={walBucket.Endpoint}, bucket={walBucket.Name}, secret=cnpg-backup-creds"); - logger.LogInformation("Created CNPG backup storage secret for bucket '{BucketName}'", walBucket.Name); - } - } - - logger.LogInformation("CloudNativePG Operator {Version} installed on cluster {Cluster}", - version, cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"CloudNativePG Operator {version} installed", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to install CNPG on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to install CloudNativePG: {ex.Message}", - Actions: actions); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Reconfigures CNPG operator. Supports: - /// - "operatorReplicas": number of operator replicas - /// - "monitoringEnabled": enable/disable Prometheus monitoring - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List setFlags = new(); - - if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas)) - { - setFlags.Add($"--set replicaCount={replicas}"); - } - - if (configuration.Values.TryGetValue("monitoringEnabled", out string? monitoring)) - { - setFlags.Add($"--set monitoring.podMonitorEnabled={monitoring}"); - setFlags.Add($"--set monitoring.grafanaDashboard.create={monitoring}"); - } - - await RunCommand("helm", - $"upgrade cnpg cloudnative-pg --repo {HelmRepo} " + - $"--namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 5m", - ct); - - actions.Add($"Reconfigured CNPG: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}"); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "CloudNativePG operator reconfigured", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to reconfigure CNPG on cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to reconfigure CloudNativePG: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls CloudNativePG operator from the cluster. Warning: existing - /// PostgreSQL Cluster resources will become unmanaged. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-cnpg-rm-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - await RunCommand("helm", - $"uninstall cnpg --namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--wait --timeout 5m", - ct); - actions.Add("Helm uninstall cnpg"); - - logger.LogInformation("CloudNativePG uninstalled from cluster {Cluster}", cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "CloudNativePG operator uninstalled successfully", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to uninstall CloudNativePG from cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to uninstall CloudNativePG: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - private async Task EnsureNamespaceWithLabels(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "cnpg-platform" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } - - /// - /// Creates or updates a Kubernetes Secret with S3 bucket credentials that - /// CNPG clusters can reference for Barman object store (WAL archiving and - /// base backups). The secret keys match what CNPG expects: - /// - ACCESS_KEY_ID: S3 access key - /// - ACCESS_SECRET_KEY: S3 secret key - /// - BUCKET_NAME: target bucket name - /// - ENDPOINT_URL: S3 endpoint URL - /// - private async Task EnsureBackupStorageSecret( - Kubernetes client, string namespaceName, StorageBucket bucket, CancellationToken ct) - { - Dictionary secretData = new() - { - ["ACCESS_KEY_ID"] = Encoding.UTF8.GetBytes(bucket.AccessKey), - ["ACCESS_SECRET_KEY"] = Encoding.UTF8.GetBytes(bucket.SecretKey), - ["BUCKET_NAME"] = Encoding.UTF8.GetBytes(bucket.Name), - ["ENDPOINT_URL"] = Encoding.UTF8.GetBytes(bucket.Endpoint) - }; - - // Include the S3 region when available. External S3 providers like - // Cleura require a region for AWS Signature V4 authentication in - // barman-cloud. Without it, WAL archiving fails silently. - - if (!string.IsNullOrWhiteSpace(bucket.Region)) - { - secretData["AWS_DEFAULT_REGION"] = Encoding.UTF8.GetBytes(bucket.Region); - } - - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = "cnpg-backup-creds", - NamespaceProperty = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "cnpg-backup" - } - }, - Data = secretData - }; - - try - { - await client.CoreV1.ReplaceNamespacedSecretAsync(secret, "cnpg-backup-creds", namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(secret, namespaceName, cancellationToken: ct); - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CnpgDatabaseProvisioner.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CnpgDatabaseProvisioner.cs deleted file mode 100644 index a8eba3d..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CnpgDatabaseProvisioner.cs +++ /dev/null @@ -1,139 +0,0 @@ -using k8s.Models; - -namespace EntKube.Clusters.Features.AdoptCluster.Components; - -/// -/// Creates databases on an existing CNPG cluster by building Kubernetes Job -/// and Secret manifests. The installers (Gitea, Harbor, Keycloak) use these -/// manifests to provision databases on the shared CNPG PostgreSQL cluster. -/// Pure manifest builders — no I/O, fully unit-testable. -/// -public static class CnpgDatabaseProvisioner -{ - /// - /// Builds the CNPG primary service FQDN from cluster name and namespace. - /// The convention is {clusterName}-rw.{namespace}.svc.cluster.local. - /// - public static string GetPrimaryHost(string clusterName, string clusterNamespace) - { - return $"{clusterName}-rw.{clusterNamespace}.svc.cluster.local"; - } - - /// - /// Builds the Kubernetes Job manifest that creates a database and owner role - /// on a CNPG cluster. The Job runs psql against the cluster's read-write - /// primary service ({clusterName}-rw). - /// - public static V1Job BuildCreateDatabaseJob( - string clusterName, - string clusterNamespace, - string databaseName, - string ownerRole, - string ownerPassword) - { - // The CNPG primary service follows the naming convention {clusterName}-rw. - - string primaryService = $"{clusterName}-rw"; - string rawJobName = $"create-db-{databaseName}-{Guid.NewGuid():N}"; - string jobName = rawJobName.Length > 63 ? rawJobName[..63] : rawJobName; - - // Build a SQL script that creates the role (if not exists) and database. - // We use CREATE ROLE ... LOGIN so the service can authenticate directly. - // The IF NOT EXISTS pattern prevents failures on re-runs. - - string sqlScript = - $"SELECT 'CREATE ROLE {ownerRole} LOGIN PASSWORD ''{ownerPassword}''' " + - $"WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{ownerRole}')\\gexec\n" + - $"SELECT 'CREATE DATABASE {databaseName} OWNER {ownerRole}' " + - $"WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '{databaseName}')\\gexec"; - - return new V1Job - { - Metadata = new V1ObjectMeta - { - Name = jobName, - NamespaceProperty = clusterNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "create-database" - } - }, - Spec = new V1JobSpec - { - BackoffLimit = 3, - TtlSecondsAfterFinished = 300, - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "create-db", - Image = "postgres:16-alpine", - Command = new List { "sh", "-c" }, - Args = new List - { - $"echo \"{sqlScript}\" | PGPASSWORD=$POSTGRES_PASSWORD psql -h {primaryService} -U postgres" - }, - Env = new List - { - new() - { - Name = "POSTGRES_PASSWORD", - ValueFrom = new V1EnvVarSource - { - SecretKeyRef = new V1SecretKeySelector - { - Name = $"{clusterName}-superuser", - Key = "password" - } - } - } - } - } - } - } - } - } - }; - } - - /// - /// Builds a Kubernetes Secret containing database credentials so the - /// consuming service (Gitea, Harbor, Keycloak) can reference them. - /// - public static V1Secret BuildCredentialsSecret( - string secretName, - string targetNamespace, - string host, - string databaseName, - string username, - string password) - { - return new V1Secret - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/purpose"] = "database-credentials" - } - }, - Type = "Opaque", - StringData = new Dictionary - { - ["host"] = host, - ["database"] = databaseName, - ["username"] = username, - ["password"] = password - } - }; - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSCheck.cs deleted file mode 100644 index 7bfa8cf..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSCheck.cs +++ /dev/null @@ -1,310 +0,0 @@ -using EntKube.Clusters.Domain; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.CustomDNS; - -/// -/// Checks the CoreDNS configuration on the cluster for customizations. -/// CoreDNS is the default DNS provider in Kubernetes and its behaviour is -/// controlled by the "coredns" ConfigMap in kube-system. This check looks for: -/// -/// 1. CoreDNS pods running in kube-system -/// 2. Custom stub zones (forwarding specific domains to external resolvers) -/// 3. Custom Corefile entries (additional plugins, rewrites, logging) -/// 4. Custom DNS records via the "coredns-custom" ConfigMap (static A/CNAME) -/// -/// When customizations are detected the discovered configuration includes the -/// raw Corefile content and any custom ConfigMap data so the platform knows -/// exactly how DNS is configured on this cluster. -/// -public class CustomDNSCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "custom-dns"; - public string DisplayName => "Custom DNS (CoreDNS)"; - - public CustomDNSCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: CoreDNS pods running in kube-system. - // Every Kubernetes cluster should have CoreDNS running — if not, - // the cluster DNS is in a broken state. - - List coreDnsPods = await FindCoreDnsPods(client, ct); - - if (coreDnsPods.Count > 0) - { - details.AddRange(coreDnsPods.Select(p => $"CoreDNS pod: {p}")); - } - else - { - missing.Add("No CoreDNS pods found in kube-system namespace"); - return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing); - } - - // Check 2: Read the main Corefile from the "coredns" ConfigMap. - // This tells us what plugins and zones CoreDNS is configured with. - - string? corefile = await GetCorefile(client, ct); - bool hasStubZones = false; - bool hasCustomEntries = false; - - if (corefile is not null) - { - details.Add("Corefile ConfigMap present"); - hasStubZones = ContainsStubZones(corefile); - hasCustomEntries = ContainsCustomEntries(corefile); - - if (hasStubZones) - { - details.Add("Stub zones detected in Corefile"); - } - - if (hasCustomEntries) - { - details.Add("Custom Corefile entries detected"); - } - } - else - { - missing.Add("coredns ConfigMap not found in kube-system"); - } - - // Check 3: Look for "coredns-custom" ConfigMap which holds additional - // server blocks, static records, or override entries that get imported - // into the main Corefile via the import directive. - - Dictionary? customRecords = await GetCustomRecordsConfigMap(client, ct); - bool hasCustomRecords = customRecords is not null && customRecords.Count > 0; - - if (hasCustomRecords) - { - details.Add($"coredns-custom ConfigMap found with {customRecords!.Count} entries"); - } - - // Discover the CoreDNS version from the pod image tag. - - string? version = await GetVersionFromPods(client, ct); - - // Build discovered configuration based on what we found. - - Dictionary values = new() - { - ["replicas"] = coreDnsPods.Count.ToString(), - ["hasStubZones"] = hasStubZones.ToString(), - ["hasCustomEntries"] = hasCustomEntries.ToString(), - ["hasCustomRecords"] = hasCustomRecords.ToString() - }; - - if (corefile is not null) - { - values["corefile"] = corefile; - } - - DiscoveredConfiguration config = new( - Version: version, - Namespace: "kube-system", - HelmReleaseName: null, - Values: values); - - // If CoreDNS is running but has no customizations, report as Installed - // but without custom configuration. If it has customizations, still Installed. - - ComponentStatus status = coreDnsPods.Count > 0 - ? ComponentStatus.Installed - : ComponentStatus.NotInstalled; - - if (missing.Count > 0) - { - status = ComponentStatus.Degraded; - } - - return new ComponentCheckResult(ComponentName, status, details, missing, config); - } - - private async Task> FindCoreDnsPods(Kubernetes client, CancellationToken ct) - { - try - { - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - "kube-system", - labelSelector: "k8s-app=kube-dns", - cancellationToken: ct); - - return podList.Items - .Where(p => p.Status?.Phase == "Running") - .Select(p => p.Metadata.Name) - .ToList(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to query CoreDNS pods"); - return new(); - } - } - - private async Task GetCorefile(Kubernetes client, CancellationToken ct) - { - try - { - V1ConfigMap configMap = await client.CoreV1.ReadNamespacedConfigMapAsync( - "coredns", "kube-system", cancellationToken: ct); - - if (configMap.Data?.TryGetValue("Corefile", out string? corefile) == true) - { - return corefile; - } - - return null; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to read coredns ConfigMap"); - return null; - } - } - - private async Task?> GetCustomRecordsConfigMap(Kubernetes client, CancellationToken ct) - { - try - { - V1ConfigMap configMap = await client.CoreV1.ReadNamespacedConfigMapAsync( - "coredns-custom", "kube-system", cancellationToken: ct); - - return configMap.Data?.ToDictionary(k => k.Key, v => v.Value); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // coredns-custom ConfigMap doesn't exist — that's fine, no custom records. - return null; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to read coredns-custom ConfigMap"); - return null; - } - } - - /// - /// Stub zones are identified by additional server blocks that forward - /// specific domains to upstream DNS servers, e.g.: - /// corp.internal:53 { forward . 10.0.0.53 } - /// - private static bool ContainsStubZones(string corefile) - { - // A stub zone typically appears as a separate server block with a forward directive - // pointing to a non-standard upstream (not /etc/resolv.conf or well-known public DNS). - string[] lines = corefile.Split('\n'); - bool insideNonDefaultBlock = false; - - foreach (string line in lines) - { - string trimmed = line.Trim(); - - // A server block for a specific domain (not the catch-all ".") indicates a stub zone. - if (trimmed.Contains(':') && trimmed.EndsWith('{') && !trimmed.StartsWith(".:")) - { - insideNonDefaultBlock = true; - } - - if (insideNonDefaultBlock && trimmed.StartsWith("forward")) - { - return true; - } - - if (trimmed == "}" && insideNonDefaultBlock) - { - insideNonDefaultBlock = false; - } - } - - return false; - } - - /// - /// Custom entries are non-default plugins like rewrite, log (when explicitly added), - /// template, or import directives pointing to custom configuration. - /// - private static bool ContainsCustomEntries(string corefile) - { - string[] customPlugins = { "rewrite", "template", "import", "hosts", "file" }; - - string[] lines = corefile.Split('\n'); - - foreach (string line in lines) - { - string trimmed = line.Trim(); - - foreach (string plugin in customPlugins) - { - if (trimmed.StartsWith(plugin + " ") || trimmed == plugin) - { - return true; - } - } - } - - return false; - } - - private async Task GetVersionFromPods(Kubernetes client, CancellationToken ct) - { - try - { - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - "kube-system", - labelSelector: "k8s-app=kube-dns", - cancellationToken: ct); - - V1Pod? firstPod = podList.Items.FirstOrDefault(); - - if (firstPod?.Spec?.Containers is null) - { - return null; - } - - string? image = firstPod.Spec.Containers - .FirstOrDefault(c => c.Name == "coredns")?.Image; - - if (image is null) - { - return null; - } - - // Image format: registry/coredns:1.11.1 or coredns/coredns:1.11.1 - int tagIndex = image.LastIndexOf(':'); - - if (tagIndex > 0 && tagIndex < image.Length - 1) - { - return image[(tagIndex + 1)..]; - } - - return null; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to determine CoreDNS version"); - return null; - } - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(cluster.KubeConfig)), - currentContext: cluster.ContextName); - - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSInstaller.cs deleted file mode 100644 index 71722a6..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/CustomDNS/CustomDNSInstaller.cs +++ /dev/null @@ -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; - -/// -/// Configures CoreDNS on the cluster by patching the "coredns" and "coredns-custom" -/// ConfigMaps in kube-system. Unlike most other components that deploy via Helm, -/// CoreDNS is already running on every Kubernetes cluster — we just customize it. -/// -/// This installer supports three types of customization: -/// -/// 1. **Stub zones** — Forward queries for specific domains to internal DNS servers. -/// Format: "domain=ip1,ip2;domain2=ip3" → generates server blocks with forward directives. -/// Example: "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53" -/// -/// 2. **Custom Corefile entries** — Additional plugin directives injected into the -/// main server block (e.g., rewrite rules, logging, template records). -/// Format: Raw Corefile plugin text (newline-separated). -/// -/// 3. **Custom DNS records** — Static A records stored in the "coredns-custom" ConfigMap -/// and imported via the hosts plugin. -/// Format: "hostname=ip;hostname2=ip2" → generates hosts-format entries. -/// -/// Configuration keys: -/// - "stubZones": Semicolon-separated zone definitions (zone=ip1,ip2) -/// - "customCorefileEntries": Raw plugin text to inject into the main server block -/// - "customRecords": Semicolon-separated static A records (hostname=ip) -/// -/// After patching the ConfigMap, CoreDNS pods automatically reload the configuration -/// (CoreDNS watches ConfigMap changes and hot-reloads within ~30s). -/// -public class CustomDNSInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string Namespace = "kube-system"; - private const string CoreDnsConfigMap = "coredns"; - private const string CoreDnsCustomConfigMap = "coredns-custom"; - - public string ComponentName => "custom-dns"; - - public CustomDNSInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - // CoreDNS is already installed on all K8s clusters. "Installing" custom DNS - // means applying the initial set of customizations to the CoreDNS ConfigMap. - - Dictionary values = options.Parameters ?? new(); - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Parse and apply stub zones if provided. - - if (values.TryGetValue("stubZones", out string? stubZonesRaw) && !string.IsNullOrWhiteSpace(stubZonesRaw)) - { - List stubZones = ParseStubZones(stubZonesRaw); - await ApplyStubZones(client, stubZones, ct); - actions.Add($"Stub zones configured: {string.Join(", ", stubZones.Select(z => z.Domain))}"); - } - - // Parse and apply custom Corefile entries if provided. - - if (values.TryGetValue("customCorefileEntries", out string? corefileEntries) && !string.IsNullOrWhiteSpace(corefileEntries)) - { - await ApplyCustomCorefileEntries(client, corefileEntries, ct); - actions.Add("Custom Corefile entries applied"); - } - - // Parse and apply custom static DNS records if provided. - - if (values.TryGetValue("customRecords", out string? recordsRaw) && !string.IsNullOrWhiteSpace(recordsRaw)) - { - Dictionary records = ParseRecords(recordsRaw); - await ApplyCustomRecords(client, records, ct); - actions.Add($"Custom records applied: {string.Join(", ", records.Keys)}"); - } - - if (actions.Count == 0) - { - actions.Add("No customizations specified — CoreDNS left unchanged"); - } - - logger.LogInformation("CoreDNS customized on cluster {Cluster}: {Actions}", - cluster.Name, string.Join("; ", actions)); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "CoreDNS customized", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to customize CoreDNS on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to customize CoreDNS: {ex.Message}", - Actions: actions); - } - } - - /// - /// Reconfigures CoreDNS. Accepts the same keys as InstallAsync — stub zones, - /// custom Corefile entries, and custom records. Each key is applied independently, - /// so you can update just the stub zones without touching records. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - // ConfigureAsync delegates to the same logic as InstallAsync since - // patching ConfigMaps is inherently idempotent — the new content - // replaces whatever was there before. - - ComponentInstallOptions options = new( - Namespace: configuration.Namespace, - Parameters: configuration.Values); - - return await InstallAsync(cluster, options, ct); - } - - /// - /// Uninstalls custom DNS configuration by removing the coredns-custom ConfigMap. - /// This reverts CoreDNS to its default configuration without stub zones or - /// custom records. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Delete the coredns-custom ConfigMap to remove all custom DNS entries. - - try - { - await client.CoreV1.DeleteNamespacedConfigMapAsync( - CoreDnsCustomConfigMap, Namespace, cancellationToken: ct); - actions.Add($"Deleted ConfigMap '{CoreDnsCustomConfigMap}' in '{Namespace}'"); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Already gone. - } - - logger.LogInformation("Custom DNS configuration removed from cluster {Cluster}", cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Custom DNS configuration removed", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to uninstall custom DNS from cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to uninstall custom DNS: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - } - - /// - /// Applies stub zones by patching the coredns ConfigMap's Corefile. Each stub zone - /// becomes a separate server block that forwards queries for that domain to the - /// specified upstream DNS servers. For example: - /// - /// corp.internal:53 { - /// errors - /// cache 30 - /// forward . 10.0.0.53 10.0.0.54 - /// } - /// - private async Task ApplyStubZones(Kubernetes client, List stubZones, CancellationToken ct) - { - // Read the current Corefile to preserve existing configuration. - - V1ConfigMap configMap = await client.CoreV1.ReadNamespacedConfigMapAsync( - CoreDnsConfigMap, Namespace, cancellationToken: ct); - - string existingCorefile = ""; - - if (configMap.Data is not null && configMap.Data.TryGetValue("Corefile", out string? corefileValue)) - { - existingCorefile = corefileValue; - } - - // Remove any previously managed stub zone blocks (marked with our comment). - // This makes the operation idempotent — reapplying won't duplicate zones. - - string cleanedCorefile = RemoveManagedStubZones(existingCorefile); - - // Build the new stub zone blocks. - - StringBuilder stubZoneBlocks = new(); - stubZoneBlocks.AppendLine("# BEGIN EntKube managed stub zones"); - - foreach (StubZone zone in stubZones) - { - string upstreams = string.Join(" ", zone.Upstreams); - stubZoneBlocks.AppendLine($"{zone.Domain}:53 {{"); - stubZoneBlocks.AppendLine(" errors"); - stubZoneBlocks.AppendLine(" cache 30"); - stubZoneBlocks.AppendLine($" forward . {upstreams}"); - stubZoneBlocks.AppendLine("}"); - } - - stubZoneBlocks.AppendLine("# END EntKube managed stub zones"); - - // Append stub zones after the main server block. - - string updatedCorefile = cleanedCorefile.TrimEnd() + "\n" + stubZoneBlocks.ToString(); - - // Patch the ConfigMap with the updated Corefile. - - configMap.Data ??= new Dictionary(); - configMap.Data["Corefile"] = updatedCorefile; - - await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsConfigMap, Namespace, cancellationToken: ct); - } - - /// - /// Applies custom Corefile entries by injecting them into the "coredns-custom" - /// ConfigMap. CoreDNS imports this via `import /etc/coredns/custom/*.server`. - /// This keeps custom entries separate from the main Corefile for cleaner management. - /// - private async Task ApplyCustomCorefileEntries(Kubernetes client, string entries, CancellationToken ct) - { - V1ConfigMap configMap = await EnsureCustomConfigMap(client, ct); - - configMap.Data ??= new Dictionary(); - configMap.Data["entkube-custom.server"] = entries; - - await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsCustomConfigMap, Namespace, cancellationToken: ct); - } - - /// - /// Applies static DNS records via the "coredns-custom" ConfigMap using hosts plugin format. - /// Each record becomes a line in a hosts-format file that CoreDNS serves. - /// - /// Example output in ConfigMap: - /// 192.168.1.100 api.local - /// 192.168.1.101 cache.local - /// - private async Task ApplyCustomRecords(Kubernetes client, Dictionary records, CancellationToken ct) - { - V1ConfigMap configMap = await EnsureCustomConfigMap(client, ct); - - // Build hosts-format content for the records. - - StringBuilder hostsContent = new(); - hostsContent.AppendLine("# BEGIN EntKube managed records"); - - foreach (KeyValuePair record in records) - { - hostsContent.AppendLine($"{record.Value} {record.Key}"); - } - - hostsContent.AppendLine("# END EntKube managed records"); - - // Store as a hosts-plugin compatible file in the custom ConfigMap. - - configMap.Data ??= new Dictionary(); - configMap.Data["entkube-records.override"] = hostsContent.ToString(); - - // Also ensure the main Corefile imports this. We add an import directive - // to the coredns-custom ConfigMap as a server block that uses the hosts plugin. - - string hostsServerBlock = - $".:53 {{\n" + - $" hosts /etc/coredns/custom/entkube-records.override {{\n" + - $" fallthrough\n" + - $" }}\n" + - $"}}"; - - configMap.Data["entkube-hosts.server"] = hostsServerBlock; - - await client.CoreV1.ReplaceNamespacedConfigMapAsync(configMap, CoreDnsCustomConfigMap, Namespace, cancellationToken: ct); - } - - /// - /// Ensures the coredns-custom ConfigMap exists. If it doesn't, create it. - /// This ConfigMap is the standard extension point for CoreDNS customizations. - /// - private async Task EnsureCustomConfigMap(Kubernetes client, CancellationToken ct) - { - try - { - return await client.CoreV1.ReadNamespacedConfigMapAsync( - CoreDnsCustomConfigMap, Namespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // ConfigMap doesn't exist yet — create it with management labels. - - V1ConfigMap newConfigMap = new() - { - Metadata = new V1ObjectMeta - { - Name = CoreDnsCustomConfigMap, - NamespaceProperty = Namespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - Data = new Dictionary() - }; - - return await client.CoreV1.CreateNamespacedConfigMapAsync(newConfigMap, Namespace, cancellationToken: ct); - } - } - - /// - /// Removes previously managed stub zone blocks from the Corefile so we can - /// reapply cleanly without duplicating entries. - /// - private static string RemoveManagedStubZones(string corefile) - { - string[] lines = corefile.Split('\n'); - StringBuilder result = new(); - bool insideManagedBlock = false; - - foreach (string line in lines) - { - if (line.TrimStart().StartsWith("# BEGIN EntKube managed stub zones")) - { - insideManagedBlock = true; - continue; - } - - if (line.TrimStart().StartsWith("# END EntKube managed stub zones")) - { - insideManagedBlock = false; - continue; - } - - if (!insideManagedBlock) - { - result.AppendLine(line); - } - } - - return result.ToString(); - } - - /// - /// Parses stub zone definitions from the format "domain=ip1,ip2;domain2=ip3". - /// Each semicolon-separated entry defines one stub zone. Within each entry, - /// the domain and upstream IPs are separated by '=', and multiple IPs by ','. - /// - private static List ParseStubZones(string raw) - { - List zones = new(); - - string[] entries = raw.Split(';', StringSplitOptions.RemoveEmptyEntries); - - foreach (string entry in entries) - { - string[] parts = entry.Split('=', 2); - - if (parts.Length != 2) - { - continue; - } - - string domain = parts[0].Trim(); - List upstreams = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(ip => ip.Trim()) - .ToList(); - - if (!string.IsNullOrWhiteSpace(domain) && upstreams.Count > 0) - { - zones.Add(new StubZone(domain, upstreams)); - } - } - - return zones; - } - - /// - /// Parses custom DNS records from the format "hostname=ip;hostname2=ip2". - /// Returns a dictionary mapping hostname → IP address. - /// - private static Dictionary ParseRecords(string raw) - { - Dictionary records = new(); - - string[] entries = raw.Split(';', StringSplitOptions.RemoveEmptyEntries); - - foreach (string entry in entries) - { - string[] parts = entry.Split('=', 2); - - if (parts.Length != 2) - { - continue; - } - - string hostname = parts[0].Trim(); - string ip = parts[1].Trim(); - - if (!string.IsNullOrWhiteSpace(hostname) && !string.IsNullOrWhiteSpace(ip)) - { - records[hostname] = ip; - } - } - - return records; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - new MemoryStream(Encoding.UTF8.GetBytes(cluster.KubeConfig)), - currentContext: cluster.ContextName); - - return new Kubernetes(config); - } - - private record StubZone(string Domain, List Upstreams); -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCACheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCACheck.cs deleted file mode 100644 index fc44192..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCACheck.cs +++ /dev/null @@ -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; - -/// -/// Checks whether domain-scoped CAs exist on the cluster. Unlike the platform -/// internal-ca (which is a single general-purpose CA), domain CAs are scoped -/// to specific DNS domains and may be: -/// -/// - **Self-signed internal CAs** for private domains (e.g., *.internal.corp.com) -/// - **Imported external CAs** with a purchased cert+key (e.g., DigiCert for *.example.com) -/// - **Trust-only CAs** where only the root cert is imported (no key, can't sign) -/// -/// Discovery looks for CA ClusterIssuers that have cert-manager Certificate policies -/// (via annotations or Kyverno policies) restricting them to specific domains. -/// Also detects TLS Secrets labeled as managed domain CAs. -/// -public class DomainCACheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "domain-ca"; - public string DisplayName => "Domain CA (Domain-Scoped Certificate Authorities)"; - - public DomainCACheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check prerequisite: cert-manager must be installed. - - bool certManagerPresent = await CertManagerCrdExists(client, ct); - - if (!certManagerPresent) - { - missing.Add("cert-manager is not installed (prerequisite for domain CAs)"); - return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing); - } - - details.Add("cert-manager CRD present (prerequisite satisfied)"); - - // Look for CA ClusterIssuers with domain annotations (EntKube-managed). - - List domainCAs = await FindDomainCAs(client, ct); - - // Also look for domain CAs discovered through trust-manager Bundle CRs. - // These are certificates added to the trust bundle — often external CAs - // imported for trust without a corresponding ClusterIssuer (no private key). - - List trustBundleCAs = await FindTrustBundleDomainCAs(client, ct); - - // Merge trust bundle CAs that aren't already in the annotated list. - - HashSet knownNames = domainCAs.Select(c => c.Name).ToHashSet(); - - foreach (DiscoveredDomainCA bundleCA in trustBundleCAs) - { - if (!knownNames.Contains(bundleCA.Name)) - { - domainCAs.Add(bundleCA); - } - } - - if (domainCAs.Count == 0) - { - // No domain CAs found — also check for managed cert ConfigMaps - // (trust-only external certs). - - List managedCerts = await FindManagedCertConfigMaps(client, ct); - - if (managedCerts.Count == 0) - { - missing.Add("No domain-scoped CAs or managed certificates found"); - return new ComponentCheckResult(ComponentName, ComponentStatus.NotInstalled, details, missing); - } - - details.AddRange(managedCerts.Select(c => $"Trust-only certificate: {c}")); - } - else - { - foreach (DiscoveredDomainCA ca in domainCAs) - { - details.Add($"Domain CA: {ca.Name} (domains: {string.Join(", ", ca.Domains)}, external: {ca.IsExternal})"); - } - } - - // Build discovered configuration summarizing what we found. - - Dictionary values = new() - { - ["domainCACount"] = domainCAs.Count.ToString(), - ["domainCAs"] = string.Join(";", domainCAs.Select(c => $"{c.Name}:{string.Join(",", c.Domains)}")) - }; - - DiscoveredConfiguration config = new( - Version: null, - Namespace: "cert-manager", - HelmReleaseName: null, - Values: values); - - return new ComponentCheckResult(ComponentName, ComponentStatus.Installed, details, missing, config); - } - - private async Task> FindDomainCAs(Kubernetes client, CancellationToken ct) - { - List result = new(); - - try - { - JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "cert-manager.io", - version: "v1", - plural: "clusterissuers", - cancellationToken: ct); - - if (issuers.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement issuer in items.EnumerateArray()) - { - // Check for EntKube domain annotation. - - if (issuer.TryGetProperty("metadata", out JsonElement metadata) && - metadata.TryGetProperty("annotations", out JsonElement annotations) && - annotations.TryGetProperty("entkube.io/domains", out JsonElement domainsEl)) - { - string? name = metadata.GetProperty("name").GetString(); - string? domains = domainsEl.GetString(); - - bool isExternal = annotations.TryGetProperty("entkube.io/external-ca", out JsonElement extEl) - && extEl.GetString() == "true"; - - if (name is not null && domains is not null) - { - result.Add(new DiscoveredDomainCA( - name, - domains.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(), - isExternal)); - } - } - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list ClusterIssuers while checking for domain CAs"); - } - - return result; - } - - private async Task> FindManagedCertConfigMaps(Kubernetes client, CancellationToken ct) - { - List result = new(); - - try - { - V1ConfigMapList configMaps = await client.CoreV1.ListNamespacedConfigMapAsync( - "cert-manager", - labelSelector: "entkube.io/component=trust-bundle", - cancellationToken: ct); - - foreach (V1ConfigMap cm in configMaps.Items) - { - result.Add(cm.Metadata.Name); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not list managed cert ConfigMaps"); - } - - return result; - } - - /// - /// Scans trust-manager Bundle CRs for certificate sources that represent - /// domain CAs. These are certificates imported into the trust bundle for - /// workload trust — often external CAs without a ClusterIssuer (trust-only). - /// This aligns DomainCACheck detection with CertificateAuthorityHandler, - /// which also discovers domain CAs from Bundle sources. - /// - private async Task> FindTrustBundleDomainCAs(Kubernetes client, CancellationToken ct) - { - List result = new(); - - try - { - JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "trust.cert-manager.io", - version: "v1alpha1", - plural: "bundles", - cancellationToken: ct); - - if (!bundles.TryGetProperty("items", out JsonElement items)) - { - return result; - } - - // Collect all Secret and ConfigMap source names referenced in Bundles. - // Each source is a CA certificate that workloads trust. - - HashSet issuerSecretNames = await GetIssuerSecretNames(client, ct); - - foreach (JsonElement bundle in items.EnumerateArray()) - { - if (!bundle.TryGetProperty("spec", out JsonElement spec) - || !spec.TryGetProperty("sources", out JsonElement sources)) - { - continue; - } - - foreach (JsonElement source in sources.EnumerateArray()) - { - // Secret sources — check if this Secret is already backing - // a ClusterIssuer (if so, it'll be found by FindDomainCAs - // if it has annotations, or it's an internal CA). - - if (source.TryGetProperty("secret", out JsonElement secretSource) - && secretSource.TryGetProperty("name", out JsonElement nameEl)) - { - string? name = nameEl.GetString(); - - if (name is not null && !issuerSecretNames.Contains(name)) - { - result.Add(new DiscoveredDomainCA(name, new List(), IsExternal: true)); - } - } - - // ConfigMap sources — imported CA certificates stored as ConfigMaps. - - if (source.TryGetProperty("configMap", out JsonElement cmSource) - && cmSource.TryGetProperty("name", out JsonElement cmNameEl)) - { - string? name = cmNameEl.GetString(); - - if (name is not null) - { - result.Add(new DiscoveredDomainCA(name, new List(), IsExternal: true)); - } - } - } - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Bundle CRD not found — trust-manager not installed. - logger.LogDebug("Bundle CRD not found — trust-manager may not be installed"); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not scan trust-manager Bundles for domain CAs"); - } - - return result; - } - - /// - /// Collects the secret names used by all CA ClusterIssuers so we can - /// exclude them from trust bundle sources (they're already tracked - /// as ClusterIssuer-backed CAs, not trust-only CAs). - /// - private async Task> GetIssuerSecretNames(Kubernetes client, CancellationToken ct) - { - HashSet secretNames = new(); - - try - { - JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "cert-manager.io", - version: "v1", - plural: "clusterissuers", - cancellationToken: ct); - - if (issuers.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement issuer in items.EnumerateArray()) - { - if (issuer.TryGetProperty("spec", out JsonElement spec) - && spec.TryGetProperty("ca", out JsonElement ca) - && ca.TryGetProperty("secretName", out JsonElement secretNameEl)) - { - string? secretName = secretNameEl.GetString(); - - if (secretName is not null) - { - secretNames.Add(secretName); - } - } - } - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not list ClusterIssuer secret names"); - } - - return secretNames; - } - - private async Task CertManagerCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "certificates.cert-manager.io", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} - -internal record DiscoveredDomainCA(string Name, List Domains, bool IsExternal); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCAInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCAInstaller.cs deleted file mode 100644 index 1a87e25..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/DomainCA/DomainCAInstaller.cs +++ /dev/null @@ -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; - -/// -/// Provisions, imports, or adopts domain-scoped Certificate Authorities. -/// Unlike the platform internal-ca (general purpose), domain CAs are restricted -/// to signing certificates only for specific DNS domains. -/// -/// Three modes of operation: -/// -/// **1. Provision a new domain CA (self-signed)** -/// Creates a new self-signed root CA scoped to specific domains. -/// Use case: internal private domains like *.internal.corp.com -/// Parameters: caName, domains, caOrganization, caDurationDays -/// -/// **2. Import an external CA (cert + key provided)** -/// Imports a purchased CA certificate and private key as a K8s TLS Secret, -/// then creates a CA ClusterIssuer referencing it. cert-manager can now sign -/// certificates for those domains using the imported CA. -/// Use case: DigiCert/Sectigo intermediate CA for *.example.com -/// Parameters: caName, importExternal=true, tlsCert, tlsKey, domains -/// -/// **3. Trust-only import (cert only, no key)** -/// Imports just the CA certificate into the trust bundle. No ClusterIssuer -/// is created because we can't sign certs without the private key. -/// Use case: Partner API CA, corporate PKI root that services need to trust -/// Parameters: caName, importExternal=true, tlsCert (no tlsKey), domains (optional) -/// -/// All modes add the CA root certificate to the trust-manager Bundle so -/// workloads automatically trust certificates signed by these CAs. -/// -/// Configuration keys: -/// - "caName": Identifier for this domain CA (used as ClusterIssuer name) -/// - "domains": Comma-separated list of domains this CA covers (e.g., "*.internal.corp.com,*.svc.local") -/// - "importExternal": "true" to import an existing cert rather than self-signing -/// - "tlsCert": Base64-encoded PEM certificate (required for import) -/// - "tlsKey": Base64-encoded PEM private key (optional — if absent, trust-only) -/// - "caOrganization": Organization field for self-signed CAs (default: "EntKube Domain CA") -/// - "caDurationDays": Validity in days for self-signed CAs (default: "1825") -/// - "bundleName": Trust bundle to add the CA to (default: "platform-trust-bundle") -/// -public class DomainCAInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string DefaultNamespace = "cert-manager"; - private const string DefaultBundleName = "platform-trust-bundle"; - - public string ComponentName => "domain-ca"; - - public DomainCAInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Check prerequisite: cert-manager must be installed. - - bool certManagerPresent = await CertManagerIsPresent(client, ct); - - if (!certManagerPresent) - { - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: "cert-manager is required but not installed", - Actions: new List { "Prerequisite check failed: cert-manager not found" }); - } - - // Read common parameters. - - string caName = "domain-ca"; - - if (options.Parameters?.TryGetValue("caName", out string? caNameVal) == true - && !string.IsNullOrEmpty(caNameVal)) - { - caName = caNameVal; - } - - string bundleName = DefaultBundleName; - - if (options.Parameters?.TryGetValue("bundleName", out string? bundleVal) == true - && !string.IsNullOrEmpty(bundleVal)) - { - bundleName = bundleVal; - } - - string domains = ""; - - if (options.Parameters?.TryGetValue("domains", out string? domainsVal) == true - && !string.IsNullOrEmpty(domainsVal)) - { - domains = domainsVal; - } - - // Route to the appropriate mode based on parameters. - - bool importExternal = options.Parameters?.TryGetValue("importExternal", out string? importVal) == true - && importVal == "true"; - - if (importExternal) - { - return await ImportExternalCA(client, caName, domains, bundleName, options, actions, ct); - } - - // ─── Mode 1: Provision a new self-signed domain CA ──────────────── - - return await ProvisionDomainCA(client, caName, domains, bundleName, options, actions, ct); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to provision/import domain CA on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to provision domain CA: {ex.Message}", - Actions: actions); - } - } - - /// - /// Reconfigures an existing domain CA. Supports updating allowed domains - /// and refreshing the trust bundle reference. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - List 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 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 { $"Error: {ex.Message}" }); - } - } - /// - /// Uninstalls a domain CA by deleting the ClusterIssuer. The caName parameter - /// identifies which domain CA to remove. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List 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 { $"Error: {ex.Message}" }); - } - } - // ─── Mode 1: Provision a new self-signed domain CA ──────────────────────── - - private async Task ProvisionDomainCA( - Kubernetes client, string caName, string domains, string bundleName, - ComponentInstallOptions options, List 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 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 ImportExternalCA( - Kubernetes client, string caName, string domains, string bundleName, - ComponentInstallOptions options, List 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 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 issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = "selfsigned-bootstrap" - }, - ["spec"] = new Dictionary - { - ["selfSigned"] = new Dictionary() - } - }; - - 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 certificate = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "Certificate", - ["metadata"] = new Dictionary - { - ["name"] = caName, - ["namespace"] = DefaultNamespace - }, - ["spec"] = new Dictionary - { - ["isCA"] = true, - ["duration"] = duration, - ["secretName"] = secretName, - ["commonName"] = $"{caName} Root CA", - ["subject"] = new Dictionary - { - ["organizations"] = new List { organization } - }, - ["privateKey"] = new Dictionary - { - ["algorithm"] = "ECDSA", - ["size"] = 256 - }, - ["issuerRef"] = new Dictionary - { - ["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 domains, bool isExternal, CancellationToken ct) - { - Dictionary annotations = new() - { - ["entkube.io/managed-by"] = "entkube-domain-ca", - ["entkube.io/domains"] = string.Join(",", domains) - }; - - if (isExternal) - { - annotations["entkube.io/external-ca"] = "true"; - } - - Dictionary issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = caName, - ["annotations"] = annotations - }, - ["spec"] = new Dictionary - { - ["ca"] = new Dictionary - { - ["secretName"] = secretName - } - } - }; - - await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", caName, issuer, ct); - } - - private async Task UpdateDomainAnnotations(Kubernetes client, string caName, List domains, CancellationToken ct) - { - Dictionary patch = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = caName, - ["annotations"] = new Dictionary - { - ["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 - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "domain-ca" - } - }, - Type = "kubernetes.io/tls", - Data = new Dictionary - { - ["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 - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "trust-bundle" - } - }, - Data = new Dictionary - { - ["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( - "trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct); - - List 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(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 - { - ["secret"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "tls.crt" - } - }); - - Dictionary patch = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary { ["name"] = bundleName }, - ["spec"] = new Dictionary { ["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 bundle = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary { ["name"] = bundleName }, - ["spec"] = new Dictionary - { - ["sources"] = new List - { - new Dictionary { ["useDefaultCAs"] = true }, - new Dictionary - { - ["secret"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "tls.crt" - } - } - }, - ["target"] = new Dictionary - { - ["configMap"] = new Dictionary - { - ["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( - "trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct); - - List 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(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 - { - ["configMap"] = new Dictionary - { - ["name"] = configMapName, - ["key"] = "ca.crt" - } - }); - - Dictionary patch = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary { ["name"] = bundleName }, - ["spec"] = new Dictionary { ["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 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 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/ExternalSecrets/ExternalSecretsCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/ExternalSecrets/ExternalSecretsCheck.cs deleted file mode 100644 index 4e79bb7..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/ExternalSecrets/ExternalSecretsCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class ExternalSecretsCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "external-secrets"; - public string DisplayName => "External Secrets Operator"; - - public ExternalSecretsCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: ESO controller pods in external-secrets namespace. - - List 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 - { - ["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 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> FindEsoPods(Kubernetes client, CancellationToken ct) - { - List 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 ExternalSecretCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "externalsecrets.external-secrets.io", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private async Task 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/ExternalSecrets/ExternalSecretsInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/ExternalSecrets/ExternalSecretsInstaller.cs deleted file mode 100644 index ea518f2..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/ExternalSecrets/ExternalSecretsInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class ExternalSecretsInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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); - } - } - } - - /// - /// 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 - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-eso-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - Kubernetes client = BuildClient(cluster); - - // Handle Helm-level configuration. - - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls External Secrets Operator from the cluster. - /// Warning: existing ExternalSecret resources will stop syncing. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Creates or updates a ClusterSecretStore for the specified provider. - /// Supports Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager. - /// - private async Task ApplyClusterSecretStore( - Kubernetes client, - string provider, - string? endpoint, - string? secretName, - string? region, - CancellationToken ct) - { - Dictionary providerSpec = provider.ToLower() switch - { - "vault" => new Dictionary - { - ["vault"] = new Dictionary - { - ["server"] = endpoint ?? "http://vault.vault:8200", - ["path"] = "secret", - ["version"] = "v2", - ["auth"] = new Dictionary - { - ["kubernetes"] = new Dictionary - { - ["mountPath"] = "kubernetes", - ["role"] = "external-secrets", - ["serviceAccountRef"] = new Dictionary - { - ["name"] = "external-secrets" - } - } - } - } - }, - - "aws" => new Dictionary - { - ["aws"] = new Dictionary - { - ["service"] = "SecretsManager", - ["region"] = region ?? "eu-north-1", - ["auth"] = string.IsNullOrEmpty(secretName) - ? new Dictionary - { - ["jwt"] = new Dictionary - { - ["serviceAccountRef"] = new Dictionary - { - ["name"] = "external-secrets" - } - } - } - : new Dictionary - { - ["secretRef"] = new Dictionary - { - ["accessKeyIDSecretRef"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "access-key-id", - ["namespace"] = "external-secrets" - }, - ["secretAccessKeySecretRef"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "secret-access-key", - ["namespace"] = "external-secrets" - } - } - } - } - }, - - "azure" => new Dictionary - { - ["azurekv"] = new Dictionary - { - ["vaultUrl"] = endpoint ?? "", - ["authType"] = "ManagedIdentity" - } - }, - - "gcp" => new Dictionary - { - ["gcpsm"] = new Dictionary - { - ["projectID"] = region ?? "", - ["auth"] = string.IsNullOrEmpty(secretName) - ? new Dictionary - { - ["workloadIdentity"] = new Dictionary - { - ["clusterLocation"] = "", - ["clusterName"] = "", - ["serviceAccountRef"] = new Dictionary - { - ["name"] = "external-secrets" - } - } - } - : new Dictionary - { - ["secretRef"] = new Dictionary - { - ["secretAccessKeySecretRef"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "secret-access-credentials", - ["namespace"] = "external-secrets" - } - } - } - } - }, - - _ => new Dictionary() - }; - - Dictionary store = new() - { - ["apiVersion"] = "external-secrets.io/v1beta1", - ["kind"] = "ClusterSecretStore", - ["metadata"] = new Dictionary - { - ["name"] = $"platform-{provider}" - }, - ["spec"] = new Dictionary - { - ["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 - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "external-secrets" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaCheck.cs deleted file mode 100644 index 3aa187d..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaCheck.cs +++ /dev/null @@ -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; - -/// -/// 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) -/// -public class GiteaCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "gitea"; - public string DisplayName => "Gitea (Source Code Management)"; - - public GiteaCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: Gitea pods in the gitea namespace. - - List 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 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 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 - { - ["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 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> FindGiteaPods(Kubernetes client, CancellationToken ct) - { - List 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> FindDatabasePods(Kubernetes client, CancellationToken ct) - { - List 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> FindRunnerPods(Kubernetes client, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaEndpoints.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaEndpoints.cs deleted file mode 100644 index e78a3e5..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaEndpoints.cs +++ /dev/null @@ -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; - -/// -/// API endpoints for Gitea runtime information. The BFF proxies these so the -/// Repositories tab can display Gitea instance info and act runner pod statuses. -/// -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.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.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 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.Ok(info)); - }); - } -} - -public record GiteaInfoResponseDto( - string? Version, - int Replicas, - bool ActionsEnabled, - List Runners); - -public record GiteaRunnerPodDto( - string Name, - string Phase, - bool Ready, - int RestartCount); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaInstaller.cs deleted file mode 100644 index 20e217f..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Gitea/GiteaInstaller.cs +++ /dev/null @@ -1,1751 +0,0 @@ -using System.Text; -using System.Text.Json; -using EntKube.Clusters.Domain; -using EntKube.Clusters.Infrastructure.Cleura; -using EntKube.SharedKernel.Domain; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Gitea; - -/// -/// Installs Gitea on a cluster using the official Helm chart. -/// Gitea is a lightweight, self-hosted Git service with built-in CI/CD -/// (Gitea Actions), package registry, and container registry support. -/// -/// What gets deployed: -/// 1. gitea namespace with management labels -/// 2. Gitea application (web UI, Git SSH, API) -/// 3. External PostgreSQL via CNPG (credentials auto-generated, stored in vault) -/// 4. Persistent storage for Git repositories -/// 5. Optional: Gitea Actions runner controller for CI/CD -/// 6. Optional: OIDC authentication (e.g. via Keycloak) -/// -public class GiteaInstaller : IComponentInstaller -{ - private readonly ILogger logger; - private readonly CleuraS3Client cleuraS3Client; - private readonly IHttpClientFactory httpClientFactory; - - private const string DefaultVersion = "10.6.0"; - private const string DefaultNamespace = "gitea"; - private const string HelmRepo = "https://dl.gitea.com/charts/"; - - public string ComponentName => "gitea"; - - public GiteaInstaller(ILogger logger, CleuraS3Client cleuraS3Client, IHttpClientFactory httpClientFactory) - { - this.logger = logger; - this.cleuraS3Client = cleuraS3Client; - this.httpClientFactory = httpClientFactory; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - // The version can come from the schema parameter, the options object, - // or fall back to the default. This allows users to select which - // version to install or upgrade to from the UI. - - string version = options.Parameters?.TryGetValue("version", out string? verVal) == true && !string.IsNullOrEmpty(verVal) - ? verVal - : options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - bool actionsEnabled = options.Parameters?.TryGetValue("actionsEnabled", out string? actVal) == true - ? actVal == "true" - : true; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-gitea-{Guid.NewGuid()}.kubeconfig"); - string valuesPath = Path.Combine(Path.GetTempPath(), $"entkube-gitea-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"); - - // Resolve object storage. The storage backend depends on the cluster's - // cloud provider — Cleura uses S3 via OpenStack Keystone, AWS uses native S3, - // Azure uses Azure Blob Storage. If explicit S3 credentials are provided in - // parameters, those take priority over auto-provisioning. - - ObjectStorageConfig? storageConfig = await ResolveObjectStorageAsync( - cluster, options.Parameters, targetNamespace, ct); - - if (storageConfig is not null) - { - // Store the credentials in a Kubernetes Secret so Gitea can reference - // them without embedding secrets in Helm values. - - await EnsureStorageSecretAsync(client, targetNamespace, storageConfig, ct); - actions.Add($"Configured {storageConfig.StorageType} object storage ({storageConfig.Endpoint})"); - } - - // If a CNPG cluster is specified, provision the Gitea database on it. - // This creates the database and role via a K8s Job, then auto-wires - // the connection parameters so BuildValues picks them up. - - await ProvisionCnpgDatabaseAsync(client, cluster, options.Parameters, targetNamespace, actions, ct); - - // If a Redis cluster is selected, resolve it to a service endpoint. - // The user picks a Redis cluster from the dropdown; we convert the - // cluster name to a host address that Gitea can connect to. - - ResolveRedisHost(cluster, options.Parameters, actions); - - // If Harbor is installed on this cluster and no explicit registry URL - // was provided, auto-detect it so act-runners use it as a mirror. - - AutoDetectHarborUrl(cluster, options.Parameters, actions); - - // Build values file based on parameters and storage config. - - string values = BuildValues(actionsEnabled, options.Parameters, storageConfig); - await File.WriteAllTextAsync(valuesPath, values, ct); - - // Install Gitea via Helm. - - await RunCommand("helm", - $"upgrade --install gitea gitea " + - $"--repo {HelmRepo} --version {version} " + - $"--namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--values \"{valuesPath}\" " + - $"--wait --timeout 15m", - ct); - actions.Add($"Helm install gitea v{version} (actions={actionsEnabled})"); - - // Deploy act runners if Actions is enabled. The runners connect to - // the Gitea instance via in-cluster service URL and register using - // a token stored in a Kubernetes Secret. If Harbor is available, - // the runners use it as a Docker registry mirror for image pulls. - - if (actionsEnabled) - { - int runnerReplicas = options.Parameters?.TryGetValue("runnerReplicas", out string? rrVal) == true - ? int.Parse(rrVal) - : 2; - string runnerStorage = options.Parameters?.TryGetValue("runnerStorage", out string? rsVal) == true - ? rsVal - : "2Gi"; - string? harborUrl = options.Parameters?.TryGetValue("harborRegistryUrl", out string? hVal) == true - ? hVal - : null; - - await DeployActRunnersAsync(client, targetNamespace, runnerReplicas, runnerStorage, harborUrl, ct); - actions.Add($"Deployed act-runner StatefulSet ({runnerReplicas} replicas, {runnerStorage} storage)"); - } - - // If SSH is enabled and an ingress provider is configured, deploy the - // appropriate TCP routing resource so git clone over SSH works through - // the ingress controller. HTTP ingress is handled by the Helm chart's - // standard Ingress resource; SSH needs provider-specific TCP routing. - - bool sshEnabled = options.Parameters?.TryGetValue("sshEnabled", out string? sshVal) != true || sshVal == "true"; - string ingressProvider = options.Parameters?.TryGetValue("ingressProvider", out string? ipVal) == true ? ipVal : ""; - string sshHost = options.Parameters?.TryGetValue("ingressHost", out string? ihVal) == true ? ihVal : ""; - int sshPort = options.Parameters?.TryGetValue("sshPort", out string? spVal) == true ? int.Parse(spVal) : 22; - - if (sshEnabled && !string.IsNullOrEmpty(ingressProvider)) - { - await DeploySshRoutingAsync(client, targetNamespace, ingressProvider, sshHost, sshPort, ct); - actions.Add($"Deployed SSH TCP routing ({ingressProvider}, port {sshPort})"); - } - - // If OIDC is configured, create a Kubernetes Secret with the OIDC - // credentials and register the authentication source via Gitea's API - // after the instance is ready. The credentials are also stored in the - // secrets vault for audit and rotation. - - if (options.Parameters?.TryGetValue("_oidcConfigured", out string? oidcFlag) == true && oidcFlag == "true") - { - await EnsureOidcSecretAsync(client, targetNamespace, options.Parameters, ct); - actions.Add("Configured OIDC authentication source"); - } - - logger.LogInformation("Gitea {Version} installed on cluster {Cluster} (actions={Actions})", - version, cluster.Name, actionsEnabled); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Gitea {version} installed (actions={actionsEnabled})", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to install Gitea on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to install Gitea: {ex.Message}", - Actions: actions); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - - if (File.Exists(valuesPath)) - { - File.Delete(valuesPath); - } - } - } - - /// - /// Reconfigures Gitea with new values. Supports: - /// - "replicas": number of Gitea replicas - /// - "actionsEnabled": enable/disable Gitea Actions - /// - "runnerReplicas": number of runner pods - /// - "persistenceSize": repository storage size - /// - "sshEnabled": enable/disable SSH - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-gitea-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List setFlags = new(); - - if (configuration.Values.TryGetValue("replicas", out string? replicas)) - { - setFlags.Add($"--set replicaCount={replicas}"); - } - - if (configuration.Values.TryGetValue("actionsEnabled", out string? actionsEnabled)) - { - setFlags.Add($"--set gitea.config.actions.ENABLED={actionsEnabled}"); - } - - if (configuration.Values.TryGetValue("persistenceSize", out string? size)) - { - setFlags.Add($"--set persistence.size={size}"); - } - - if (configuration.Values.TryGetValue("sshEnabled", out string? ssh)) - { - setFlags.Add($"--set gitea.config.server.DISABLE_SSH={(!bool.Parse(ssh)).ToString().ToLower()}"); - } - - await RunCommand("helm", - $"upgrade gitea gitea --repo {HelmRepo} " + - $"--namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--reuse-values {string.Join(" ", setFlags)} --wait --timeout 10m", - ct); - - // If runner replicas are being reconfigured, scale the StatefulSet directly. - - if (configuration.Values.TryGetValue("runnerReplicas", out string? runnerReplicas)) - { - Kubernetes client = BuildClient(cluster); - int newReplicas = int.Parse(runnerReplicas); - - await ScaleActRunnersAsync(client, targetNamespace, newReplicas, ct); - actions.Add($"Scaled act-runner to {newReplicas} replicas"); - } - - actions.Add($"Reconfigured Gitea: {string.Join(", ", configuration.Values.Select(kv => $"{kv.Key}={kv.Value}"))}"); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Gitea reconfigured", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to reconfigure Gitea on cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to reconfigure Gitea: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls Gitea from the cluster by removing the Helm release. - /// Warning: all repositories and CI/CD data stored in Gitea will be lost - /// unless backed up to external storage. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-gitea-rm-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - await RunCommand("helm", - $"uninstall gitea --namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--wait --timeout 5m", - ct); - actions.Add("Helm uninstall gitea"); - - logger.LogInformation("Gitea uninstalled from cluster {Cluster}", cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Gitea uninstalled successfully", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to uninstall Gitea from cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to uninstall Gitea: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Deploys the act-runner StatefulSet for Gitea Actions CI/CD. - /// The runners use DinD (Docker-in-Docker) rootless mode with a Unix socket. - /// They register with the Gitea instance using a token from the runner-secret. - /// - /// The StatefulSet pattern ensures: - /// - Stable network identities for runners - /// - Persistent /data volume for caches between builds - /// - Ordered scaling for predictable registration - /// - private async Task DeployActRunnersAsync( - Kubernetes client, string targetNamespace, int replicas, string storageSize, string? harborRegistryUrl, CancellationToken ct) - { - V1StatefulSet statefulSet = BuildActRunnerStatefulSet(targetNamespace, replicas, storageSize, harborRegistryUrl); - - try - { - // Try to get existing — if found, update it. - - await client.AppsV1.ReadNamespacedStatefulSetAsync("act-runner", targetNamespace, cancellationToken: ct); - await client.AppsV1.ReplaceNamespacedStatefulSetAsync(statefulSet, "act-runner", targetNamespace, cancellationToken: ct); - - logger.LogInformation("Updated act-runner StatefulSet in {Namespace} ({Replicas} replicas)", targetNamespace, replicas); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Doesn't exist yet — create it. - - await client.AppsV1.CreateNamespacedStatefulSetAsync(statefulSet, targetNamespace, cancellationToken: ct); - - logger.LogInformation("Created act-runner StatefulSet in {Namespace} ({Replicas} replicas)", targetNamespace, replicas); - } - } - - /// - /// Scales the act-runner StatefulSet to the desired replica count. - /// Uses a JSON merge patch to only update the replicas field. - /// - private async Task ScaleActRunnersAsync(Kubernetes client, string targetNamespace, int replicas, CancellationToken ct) - { - try - { - string patchJson = $"{{\"spec\": {{\"replicas\": {replicas}}}}}"; - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.AppsV1.PatchNamespacedStatefulSetAsync(patch, "act-runner", targetNamespace, cancellationToken: ct); - - logger.LogInformation("Scaled act-runner to {Replicas} replicas in {Namespace}", replicas, targetNamespace); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogWarning("act-runner StatefulSet not found in {Namespace} — cannot scale", targetNamespace); - } - } - - /// - /// Deploys TCP routing for Git SSH access based on the cluster's ingress provider. - /// SSH is a raw TCP protocol and can't go through standard HTTP ingress, so each - /// provider needs its own resource type: - /// - Traefik: IngressRouteTCP with a HostSNI match - /// - Istio: Gateway listener + TCPRoute (via Gateway API) - /// - Gateway API: TLSRoute with passthrough on the Gateway - /// - private async Task DeploySshRoutingAsync( - Kubernetes client, string targetNamespace, string provider, string host, int sshPort, CancellationToken ct) - { - switch (provider.ToLowerInvariant()) - { - case "traefik": - await ApplyTraefikSshRoute(client, targetNamespace, host, sshPort, ct); - break; - - case "istio": - case "gatewayapi": - await ApplyGatewayApiSshRoute(client, targetNamespace, host, sshPort, ct); - break; - - default: - logger.LogWarning("Unknown ingress provider '{Provider}' — skipping SSH routing", provider); - break; - } - } - - /// - /// Creates a Traefik IngressRouteTCP that routes SSH traffic to the Gitea SSH - /// service. Traefik needs a dedicated entrypoint for SSH (typically "ssh" or - /// "gitssh") configured in its static config. The route uses HostSNI(`*`) - /// because SSH doesn't carry SNI — the entrypoint port is what differentiates it. - /// - private async Task ApplyTraefikSshRoute( - Kubernetes client, string targetNamespace, string host, int sshPort, CancellationToken ct) - { - Dictionary route = new() - { - ["apiVersion"] = "traefik.io/v1alpha1", - ["kind"] = "IngressRouteTCP", - ["metadata"] = new Dictionary - { - ["name"] = "gitea-ssh", - ["namespace"] = targetNamespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/name"] = "gitea", - ["app.kubernetes.io/component"] = "ssh-ingress", - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - ["spec"] = new Dictionary - { - ["entryPoints"] = new List { "gitssh" }, - ["routes"] = new List> - { - new() - { - ["match"] = "HostSNI(`*`)", - ["services"] = new List> - { - new() - { - ["name"] = "gitea-ssh", - ["port"] = sshPort - } - } - } - } - } - }; - - await ApplyNamespacedCustomObject( - client, "traefik.io", "v1alpha1", targetNamespace, "ingressroutetcps", "gitea-ssh", route, ct); - - logger.LogInformation("Applied Traefik IngressRouteTCP for Gitea SSH in {Namespace}", targetNamespace); - } - - /// - /// Creates Gateway API resources for SSH access. This works with both Istio - /// and any Gateway API-compatible controller. We add a TCP listener to the - /// existing gateway and create a TCPRoute pointing to the Gitea SSH service. - /// - /// The Gateway must already exist (deployed by IngressInstaller). We create - /// a separate TCPRoute that references it via parentRefs. - /// - private async Task ApplyGatewayApiSshRoute( - Kubernetes client, string targetNamespace, string host, int sshPort, CancellationToken ct) - { - // TCPRoute — routes raw TCP traffic from the gateway's SSH listener - // to the Gitea SSH service. No TLS termination needed since SSH - // handles its own encryption. - - Dictionary tcpRoute = new() - { - ["apiVersion"] = "gateway.networking.k8s.io/v1alpha2", - ["kind"] = "TCPRoute", - ["metadata"] = new Dictionary - { - ["name"] = "gitea-ssh", - ["namespace"] = targetNamespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/name"] = "gitea", - ["app.kubernetes.io/component"] = "ssh-ingress", - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - ["spec"] = new Dictionary - { - ["parentRefs"] = new List> - { - new() - { - ["name"] = "internal-ingress", - ["namespace"] = "internal-ingress", - ["sectionName"] = "gitssh" - } - }, - ["rules"] = new List> - { - new() - { - ["backendRefs"] = new List> - { - new() - { - ["name"] = "gitea-ssh", - ["port"] = sshPort - } - } - } - } - } - }; - - await ApplyNamespacedCustomObject( - client, "gateway.networking.k8s.io", "v1alpha2", targetNamespace, "tcproutes", "gitea-ssh", tcpRoute, ct); - - logger.LogInformation("Applied Gateway API TCPRoute for Gitea SSH in {Namespace}", targetNamespace); - } - - /// - /// Idempotent apply for a namespaced custom object. Tries to patch an existing - /// resource; if it doesn't exist yet, creates it. This mirrors the pattern - /// used by IngressInstaller and KeycloakInstaller. - /// - private async Task ApplyNamespacedCustomObject( - Kubernetes client, string group, string version, string ns, - string plural, string name, Dictionary body, CancellationToken ct) - { - try - { - await client.CustomObjects.GetNamespacedCustomObjectAsync( - group, version, ns, plural, name, cancellationToken: ct); - - // Resource exists — patch it with the new spec. - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - new V1Patch(body, V1Patch.PatchType.MergePatch), - group, version, ns, plural, name, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Resource doesn't exist yet — create it. - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body, group, version, ns, plural, cancellationToken: ct); - } - } - - /// - /// Builds the act-runner StatefulSet spec matching the production template. - /// Uses the rootless DinD image with Unix socket for Docker access. - /// The runner registers with Gitea via the in-cluster service URL and - /// authenticates using a registration token from the runner-secret. - /// When a Harbor registry URL is provided, the runner's Docker daemon - /// is configured to use it as a registry mirror for image pulls. - /// - public static V1StatefulSet BuildActRunnerStatefulSet( - string targetNamespace, int replicas, string storageSize, string? harborRegistryUrl = null) - { - return new V1StatefulSet - { - Metadata = new V1ObjectMeta - { - Name = "act-runner", - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/name"] = "gitea", - ["app.kubernetes.io/component"] = "runner", - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - Spec = new V1StatefulSetSpec - { - Replicas = replicas, - ServiceName = "act-runner", - Selector = new V1LabelSelector - { - MatchLabels = new Dictionary { ["app"] = "act-runner" } - }, - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta - { - Labels = new Dictionary - { - ["app"] = "act-runner", - ["app.kubernetes.io/name"] = "gitea", - ["app.kubernetes.io/component"] = "runner" - } - }, - Spec = new V1PodSpec - { - SecurityContext = new V1PodSecurityContext - { - FsGroup = 1000, - SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" } - }, - Containers = new List - { - new() - { - Name = "runner", - Image = "gitea/act_runner:latest-dind-rootless", - ImagePullPolicy = "Always", - Env = new List - { - new() { Name = "DOCKER_HOST", Value = "unix:///tmp/xdg/docker.sock" }, - new() { Name = "XDG_RUNTIME_DIR", Value = "/tmp/xdg" }, - new() { Name = "GITEA_INSTANCE_URL", Value = $"http://gitea-http.{targetNamespace}.svc.cluster.local:3000" }, - new() - { - Name = "GITEA_RUNNER_REGISTRATION_TOKEN", - ValueFrom = new V1EnvVarSource - { - SecretKeyRef = new V1SecretKeySelector - { - Name = "runner-secret", - Key = "token" - } - } - } - }.Concat(BuildHarborMirrorEnvVars(harborRegistryUrl)).ToList(), - SecurityContext = new V1SecurityContext - { - Privileged = true, - ReadOnlyRootFilesystem = true - }, - Resources = new V1ResourceRequirements - { - Requests = new Dictionary - { - ["cpu"] = new ResourceQuantity("100m"), - ["memory"] = new ResourceQuantity("256Mi") - }, - Limits = new Dictionary - { - ["cpu"] = new ResourceQuantity("2"), - ["memory"] = new ResourceQuantity("2Gi") - } - }, - VolumeMounts = new List - { - new() { Name = "runner-data", MountPath = "/data" } - } - } - } - } - }, - VolumeClaimTemplates = new List - { - new() - { - Metadata = new V1ObjectMeta { Name = "runner-data" }, - Spec = new V1PersistentVolumeClaimSpec - { - AccessModes = new List { "ReadWriteOnce" }, - Resources = new V1VolumeResourceRequirements - { - Requests = new Dictionary - { - ["storage"] = new ResourceQuantity(storageSize) - } - } - } - } - }, - PersistentVolumeClaimRetentionPolicy = new V1StatefulSetPersistentVolumeClaimRetentionPolicy - { - WhenDeleted = "Retain", - WhenScaled = "Retain" - } - } - }; - } - - /// - /// Builds environment variables for configuring the runner's Docker daemon to - /// use Harbor as a registry mirror. When CI workflows pull images (e.g., - /// docker.io/library/node:20), Docker will try Harbor's proxy cache first, - /// reducing external bandwidth and improving pull speed. - /// - private static IEnumerable BuildHarborMirrorEnvVars(string? harborRegistryUrl) - { - if (string.IsNullOrEmpty(harborRegistryUrl)) - { - yield break; - } - - // The DinD rootless daemon reads DOCKERD_ROOTLESS_FLAGS for additional - // daemon arguments. We inject --registry-mirror so all pulls go through - // Harbor's proxy cache project for docker.io. - - yield return new V1EnvVar - { - Name = "DOCKERD_ROOTLESS_FLAGS", - Value = $"--registry-mirror=https://{harborRegistryUrl}" - }; - } - - private static string BuildValues( - bool actionsEnabled, Dictionary? parameters, ObjectStorageConfig? storageConfig = null) - { - // Extract parameters with sensible defaults matching the production template. - // Every value here has been validated against a working Gitea 10.x deployment. - - string replicas = GetParam(parameters, "replicas", "1"); - string persistenceSize = GetParam(parameters, "persistenceSize", "50Gi"); - string adminUsername = GetParam(parameters, "adminUsername", "gitea_admin"); - string adminEmail = GetParam(parameters, "adminEmail", "admin@local.domain"); - string sshPort = GetParam(parameters, "sshPort", "22"); - bool sshEnabled = GetParam(parameters, "sshEnabled", "true") == "true"; - - // Database configuration — the CNPG provisioner auto-wires dbHost, dbUser, - // and dbPassword into the parameters dictionary when a CNPG cluster is - // selected. No manual input needed from the user. - - string dbHost = GetParam(parameters, "dbHost", ""); - string dbName = GetParam(parameters, "dbName", "gitea"); - string dbUser = GetParam(parameters, "dbUser", "gitea"); - string dbPassword = GetParam(parameters, "dbPassword", "gitea"); - string dbSchema = GetParam(parameters, "dbSchema", "gitea"); - bool externalDb = !string.IsNullOrEmpty(dbHost); - - // Redis configuration — resolved from the selected Redis cluster name. - // The installer resolves the cluster name to a service endpoint before - // calling BuildValues (see ResolveRedisHost). Falls back to in-memory. - - string redisHost = GetParam(parameters, "redisHost", ""); - - // Ingress — only included when a host is specified. The provider determines - // which annotations to apply (Traefik, Istio, or plain Kubernetes ingress). - - string ingressHost = GetParam(parameters, "ingressHost", ""); - string ingressProvider = GetParam(parameters, "ingressProvider", "traefik"); - bool ingressEnabled = !string.IsNullOrEmpty(ingressHost); - - // Feature toggles with secure-by-default settings. - - bool metricsEnabled = GetParam(parameters, "metricsEnabled", "true") == "true"; - bool disableRegistration = GetParam(parameters, "disableRegistration", "true") == "true"; - bool projectsEnabled = GetParam(parameters, "projectsEnabled", "true") == "true"; - - // OIDC configuration — when enabled, Gitea uses OpenID Connect for - // authentication (typically via Keycloak). Local login is disabled - // automatically when OIDC is enabled for security. - - bool oidcEnabled = GetParam(parameters, "oidcEnabled", "false") == "true"; - bool disableLocalLogin = oidcEnabled || GetParam(parameters, "disableLocalLogin", "true") == "true"; - - // The admin password is auto-generated by the chart if not set explicitly. - // We only inject it if the user provides one. - - string adminPasswordLine = ""; - - if (parameters?.TryGetValue("adminPassword", out string? pwd) == true && !string.IsNullOrEmpty(pwd)) - { - adminPasswordLine = $"\n password: \"{pwd}\"\n passwordMode: keepUpdated"; - } - - // Build the values YAML. This closely mirrors the production template — - // hardened security context, rootless image, headless services, - // rolling update strategy, and pod disruption budget. - - StringBuilder yaml = new(); - - yaml.AppendLine($"replicaCount: {replicas}"); - yaml.AppendLine(); - - // Container image — always rootless for security. - - yaml.AppendLine("image:"); - yaml.AppendLine(" registry: docker.gitea.com"); - yaml.AppendLine(" repository: gitea"); - yaml.AppendLine(" rootless: true"); - yaml.AppendLine(" pullPolicy: IfNotPresent"); - yaml.AppendLine(); - - // Container security — locked down with readOnlyRootFilesystem, non-root, - // and only the SYS_CHROOT capability needed for rootless Git operations. - - yaml.AppendLine("containerSecurityContext:"); - yaml.AppendLine(" allowPrivilegeEscalation: false"); - yaml.AppendLine(" capabilities:"); - yaml.AppendLine(" add:"); - yaml.AppendLine(" - SYS_CHROOT"); - yaml.AppendLine(" drop:"); - yaml.AppendLine(" - ALL"); - yaml.AppendLine(" privileged: false"); - yaml.AppendLine(" readOnlyRootFilesystem: true"); - yaml.AppendLine(" runAsGroup: 1000"); - yaml.AppendLine(" runAsNonRoot: true"); - yaml.AppendLine(" runAsUser: 1000"); - yaml.AppendLine(); - - // Pod security context — fsGroup ensures volume permissions match the container user. - - yaml.AppendLine("podSecurityContext:"); - yaml.AppendLine(" fsGroup: 1000"); - yaml.AppendLine(); - - // Deployment settings — rolling update with zero-downtime strategy. - - yaml.AppendLine("deployment:"); - yaml.AppendLine(" terminationGracePeriodSeconds: 60"); - yaml.AppendLine(); - yaml.AppendLine("strategy:"); - yaml.AppendLine(" type: RollingUpdate"); - yaml.AppendLine(" rollingUpdate:"); - yaml.AppendLine(" maxSurge: 100%"); - yaml.AppendLine(" maxUnavailable: 0"); - yaml.AppendLine(); - - // Pod disruption budget — ensure at least one pod stays available during disruptions. - - yaml.AppendLine("podDisruptionBudget:"); - yaml.AppendLine(" minAvailable: 1"); - yaml.AppendLine(); - - // Gitea application configuration. - - yaml.AppendLine("gitea:"); - yaml.AppendLine(" admin:"); - yaml.AppendLine($" username: \"{adminUsername}\""); - yaml.AppendLine($" email: \"{adminEmail}\""); - yaml.Append(adminPasswordLine); - yaml.AppendLine(" config:"); - - // Database — external PostgreSQL (CNPG) is required. The embedded - // sub-chart is always disabled to avoid bitnami image dependencies. - - if (externalDb) - { - yaml.AppendLine(" database:"); - yaml.AppendLine(" DB_TYPE: postgres"); - yaml.AppendLine($" HOST: {dbHost}"); - yaml.AppendLine($" NAME: {dbName}"); - yaml.AppendLine($" USER: {dbUser}"); - yaml.AppendLine($" PASSWD: {dbPassword}"); - yaml.AppendLine($" SCHEMA: {dbSchema}"); - } - - // Cache/session — external Redis if provided, otherwise memory (no sub-chart). - - if (!string.IsNullOrEmpty(redisHost)) - { - yaml.AppendLine(" cache:"); - yaml.AppendLine(" ADAPTER: redis"); - yaml.AppendLine($" HOST: redis://{redisHost}:6379/0"); - yaml.AppendLine(" session:"); - yaml.AppendLine(" PROVIDER: redis"); - yaml.AppendLine($" PROVIDER_CONFIG: redis://{redisHost}:6379/1"); - yaml.AppendLine(" queue:"); - yaml.AppendLine(" TYPE: redis"); - yaml.AppendLine($" CONN_STR: redis://{redisHost}:6379/2"); - } - else - { - yaml.AppendLine(" cache:"); - yaml.AppendLine(" ADAPTER: memory"); - yaml.AppendLine(" session:"); - yaml.AppendLine(" PROVIDER: memory"); - yaml.AppendLine(" queue:"); - yaml.AppendLine(" TYPE: level"); - } - - // Security — disable local login when external auth (Keycloak) is configured. - - if (disableLocalLogin) - { - yaml.AppendLine(" security:"); - yaml.AppendLine(" DISABLE_LOCAL_LOGIN: true"); - } - - // Server — SSH configuration and LFS support. - - yaml.AppendLine(" server:"); - yaml.AppendLine($" DISABLE_SSH: {(!sshEnabled).ToString().ToLower()}"); - - if (sshEnabled) - { - yaml.AppendLine(" SSH_LISTEN_PORT: 2222"); - yaml.AppendLine($" SSH_PORT: {sshPort}"); - yaml.AppendLine(" START_SSH_SERVER: true"); - } - - yaml.AppendLine(" LFS_START_SERVER: true"); - - // Service registration — secure defaults for multi-tenant environments. - - yaml.AppendLine(" service:"); - yaml.AppendLine($" DISABLE_REGISTRATION: {disableRegistration.ToString().ToLower()}"); - - if (disableRegistration) - { - yaml.AppendLine(" ALLOW_ONLY_EXTERNAL_REGISTRATION: true"); - yaml.AppendLine(" ALLOW_ONLY_INTERNAL_REGISTRATION: false"); - } - - // Actions, packages, repository, projects, and indexer configuration. - - yaml.AppendLine(" actions:"); - yaml.AppendLine($" ENABLED: \"{actionsEnabled.ToString().ToLower()}\""); - yaml.AppendLine(" repository:"); - yaml.AppendLine(" DEFAULT_BRANCH: main"); - yaml.AppendLine(" ENABLE_PUSH_CREATE_USER: true"); - yaml.AppendLine(" packages:"); - yaml.AppendLine(" ENABLED: true"); - yaml.AppendLine(" project:"); - yaml.AppendLine($" PROJECT_BOARD_BASIC_KANBAN_TYPE: To Do, In Progress, Done"); - yaml.AppendLine($" ENABLE_PROJECT: {projectsEnabled.ToString().ToLower()}"); - yaml.AppendLine(" indexer:"); - yaml.AppendLine(" ISSUE_INDEXER_TYPE: bleve"); - yaml.AppendLine(" REPO_INDEXER_ENABLED: true"); - - // OIDC — when enabled, register an OAuth2 authentication source so - // users can log in via an external identity provider (e.g. Keycloak). - // Gitea uses the [oauth2] section for OIDC provider configuration - // and the [openid] section for OpenID Connect discovery. - - if (oidcEnabled) - { - string oidcProviderName = GetParam(parameters, "oidcProviderName", "Keycloak"); - string oidcDiscoveryUrl = GetParam(parameters, "oidcDiscoveryUrl", ""); - string oidcClientId = GetParam(parameters, "oidcClientId", ""); - string oidcClientSecret = GetParam(parameters, "oidcClientSecret", ""); - string oidcScopes = GetParam(parameters, "oidcScopes", "openid profile email"); - bool oidcAutoDiscover = GetParam(parameters, "oidcAutoDiscoverUrl", "true") == "true"; - - yaml.AppendLine(" oauth2:"); - yaml.AppendLine(" ENABLE: true"); - yaml.AppendLine(" ENABLED: true"); - yaml.AppendLine(" openid:"); - yaml.AppendLine(" ENABLE_OPENID_SIGNIN: true"); - yaml.AppendLine(" ENABLE_OPENID_SIGNUP: true"); - - // The actual OIDC provider is configured via additionalConfigFromEnvs - // and an init script, because the Helm chart doesn't expose per-provider - // OIDC configuration in gitea.ini natively. We store the OIDC credentials - // in a Kubernetes Secret and inject them as environment variables. - - if (!string.IsNullOrEmpty(oidcDiscoveryUrl) && !string.IsNullOrEmpty(oidcClientId)) - { - parameters!["_oidcConfigured"] = "true"; - } - } - - // Object storage — when configured, Gitea stores LFS objects, packages, - // attachments, and repo archives in the external object store instead of - // the local PVC. This is critical for production: PVC storage doesn't - // scale well and isn't replicated across zones. - - if (storageConfig is not null) - { - AppendObjectStorageConfig(yaml, storageConfig); - } - - // Metrics — enabled by default for monitoring with ServiceMonitor for Prometheus. - - yaml.AppendLine(" metrics:"); - yaml.AppendLine($" enabled: {metricsEnabled.ToString().ToLower()}"); - yaml.AppendLine(" serviceMonitor:"); - yaml.AppendLine($" enabled: {metricsEnabled.ToString().ToLower()}"); - - // Probes — production-tested values with generous startup for initial indexing. - - yaml.AppendLine(" livenessProbe:"); - yaml.AppendLine(" enabled: true"); - yaml.AppendLine(" initialDelaySeconds: 200"); - yaml.AppendLine(" periodSeconds: 10"); - yaml.AppendLine(" failureThreshold: 10"); - yaml.AppendLine(" tcpSocket:"); - yaml.AppendLine(" port: http"); - yaml.AppendLine(" readinessProbe:"); - yaml.AppendLine(" enabled: true"); - yaml.AppendLine(" initialDelaySeconds: 5"); - yaml.AppendLine(" periodSeconds: 10"); - yaml.AppendLine(" failureThreshold: 3"); - yaml.AppendLine(" tcpSocket:"); - yaml.AppendLine(" port: http"); - yaml.AppendLine(); - - // Persistence — Git repository storage with retain policy. - - yaml.AppendLine("persistence:"); - yaml.AppendLine(" enabled: true"); - yaml.AppendLine($" size: {persistenceSize}"); - yaml.AppendLine(" accessModes:"); - yaml.AppendLine(" - ReadWriteOnce"); - yaml.AppendLine(" annotations:"); - yaml.AppendLine(" helm.sh/resource-policy: keep"); - yaml.AppendLine(" storageClass: default"); - yaml.AppendLine(); - - // Services — headless ClusterIP for both HTTP and SSH. - - yaml.AppendLine("service:"); - yaml.AppendLine(" http:"); - yaml.AppendLine(" type: ClusterIP"); - yaml.AppendLine(" clusterIP: None"); - yaml.AppendLine(" port: 3000"); - yaml.AppendLine(" ssh:"); - yaml.AppendLine(" type: ClusterIP"); - yaml.AppendLine(" clusterIP: None"); - yaml.AppendLine($" port: {sshPort}"); - yaml.AppendLine(); - - // Ingress — HTTP ingress for the Gitea web UI. Annotations are - // provider-specific. SSH routing is handled separately via custom - // resources (IngressRouteTCP, TCPRoute) deployed in InstallAsync. - - if (ingressEnabled) - { - yaml.AppendLine("ingress:"); - yaml.AppendLine(" enabled: true"); - yaml.AppendLine(" annotations:"); - yaml.AppendLine(" cert-manager.io/cluster-issuer: letsencrypt-production"); - - switch (ingressProvider.ToLowerInvariant()) - { - case "traefik": - yaml.AppendLine(" traefik.ingress.kubernetes.io/router.entrypoints: websecure"); - yaml.AppendLine(" traefik.ingress.kubernetes.io/router.tls: \"true\""); - yaml.AppendLine(" traefik.ingress.kubernetes.io/forwarded-headers: \"true\""); - yaml.AppendLine(" traefik.ingress.kubernetes.io/forward-auth-response-headers: Set-Cookie, Cookie"); - break; - - case "istio": - yaml.AppendLine(" kubernetes.io/ingress.class: istio"); - break; - - case "gatewayapi": - // Gateway API uses HTTPRoute, not Ingress annotations. - // The Helm chart's Ingress resource still works with - // controllers that support the Kubernetes Ingress API. - break; - } - - yaml.AppendLine(" hosts:"); - yaml.AppendLine($" - host: {ingressHost}"); - yaml.AppendLine(" paths:"); - yaml.AppendLine(" - path: /"); - yaml.AppendLine(" pathType: Prefix"); - yaml.AppendLine(" tls:"); - yaml.AppendLine(" - hosts:"); - yaml.AppendLine($" - {ingressHost}"); - yaml.AppendLine(" secretName: gitea-tls"); - yaml.AppendLine(); - } - - // Resources — production-grade defaults. Gitea needs headroom for - // repository indexing and large pushes. - - yaml.AppendLine("resources:"); - yaml.AppendLine(" requests:"); - yaml.AppendLine(" cpu: 100m"); - yaml.AppendLine(" memory: 128Mi"); - yaml.AppendLine(" limits:"); - yaml.AppendLine(" cpu: \"4\""); - yaml.AppendLine(" memory: 6Gi"); - yaml.AppendLine(); - - // Init container resources — small footprint for setup tasks. - - yaml.AppendLine("initContainers:"); - yaml.AppendLine(" resources:"); - yaml.AppendLine(" requests:"); - yaml.AppendLine(" cpu: 100m"); - yaml.AppendLine(" memory: 128Mi"); - yaml.AppendLine(); - - // All sub-charts disabled. The Gitea Helm chart bundles bitnami - // PostgreSQL and Redis sub-charts, but bitnami images are not allowed - // in this platform. External services (CNPG, platform Redis) are used instead. - - yaml.AppendLine("postgresql:"); - yaml.AppendLine(" enabled: false"); - yaml.AppendLine("postgresql-ha:"); - yaml.AppendLine(" enabled: false"); - yaml.AppendLine("redis:"); - yaml.AppendLine(" enabled: false"); - yaml.AppendLine("redis-cluster:"); - yaml.AppendLine(" enabled: false"); - yaml.AppendLine(); - - // Service account — created automatically for RBAC. - - yaml.AppendLine("serviceAccount:"); - yaml.AppendLine(" create: true"); - yaml.AppendLine(" automountServiceAccountToken: true"); - yaml.AppendLine(); - - // Test pod — disabled in production to avoid unnecessary resource usage. - - yaml.AppendLine("test:"); - yaml.AppendLine(" enabled: false"); - - return yaml.ToString(); - } - - /// - /// Appends the Gitea storage configuration to the values YAML. Gitea supports - /// two object storage types: "minio" (any S3-compatible) and "azureblob". - /// The [storage] section sets the global default; all subsystems (LFS, packages, - /// attachments, repo-archive) inherit from it automatically. - /// - /// Credentials reference a Kubernetes Secret created by EnsureStorageSecretAsync - /// so they don't appear in plain text in Helm values. - /// - private static void AppendObjectStorageConfig(StringBuilder yaml, ObjectStorageConfig config) - { - switch (config.StorageType) - { - case "s3": - // Gitea calls all S3-compatible storage "minio" regardless of - // the actual provider (AWS S3, Cleura, DigitalOcean Spaces, etc.) - - yaml.AppendLine(" storage:"); - yaml.AppendLine(" STORAGE_TYPE: minio"); - yaml.AppendLine($" MINIO_ENDPOINT: {config.Endpoint}"); - yaml.AppendLine($" MINIO_BUCKET: {config.Bucket}"); - yaml.AppendLine($" MINIO_LOCATION: {config.Region}"); - yaml.AppendLine($" MINIO_USE_SSL: {config.UseSsl.ToString().ToLower()}"); - yaml.AppendLine(" MINIO_INSECURE_SKIP_VERIFY: false"); - - // Credentials are injected via additionalConfigFromEnvs referencing - // the gitea-storage-secret Secret. - - break; - - case "azureblob": - // Azure Blob Storage — native support in Gitea. - - yaml.AppendLine(" storage:"); - yaml.AppendLine(" STORAGE_TYPE: azureblob"); - yaml.AppendLine($" AZURE_BLOB_CONTAINER: {config.Bucket}"); - - break; - } - - // Inject storage credentials from the Kubernetes Secret as environment - // variables. Gitea reads these via additionalConfigFromEnvs, which maps - // env vars to gitea.ini config keys using the GITEA__SECTION__KEY format. - - yaml.AppendLine(" additionalConfigFromEnvs:"); - - if (config.StorageType == "s3") - { - yaml.AppendLine(" - name: GITEA__STORAGE__MINIO_ACCESS_KEY_ID"); - yaml.AppendLine(" valueFrom:"); - yaml.AppendLine(" secretKeyRef:"); - yaml.AppendLine(" name: gitea-storage-secret"); - yaml.AppendLine(" key: access-key"); - yaml.AppendLine(" - name: GITEA__STORAGE__MINIO_SECRET_ACCESS_KEY"); - yaml.AppendLine(" valueFrom:"); - yaml.AppendLine(" secretKeyRef:"); - yaml.AppendLine(" name: gitea-storage-secret"); - yaml.AppendLine(" key: secret-key"); - } - else if (config.StorageType == "azureblob") - { - yaml.AppendLine(" - name: GITEA__STORAGE__AZURE_BLOB_ACCOUNT_NAME"); - yaml.AppendLine(" valueFrom:"); - yaml.AppendLine(" secretKeyRef:"); - yaml.AppendLine(" name: gitea-storage-secret"); - yaml.AppendLine(" key: account-name"); - yaml.AppendLine(" - name: GITEA__STORAGE__AZURE_BLOB_ACCOUNT_KEY"); - yaml.AppendLine(" valueFrom:"); - yaml.AppendLine(" secretKeyRef:"); - yaml.AppendLine(" name: gitea-storage-secret"); - yaml.AppendLine(" key: account-key"); - } - } - - /// - /// Resolves the object storage configuration for Gitea based on the cluster's - /// cloud provider. The priority is: - /// 1. Explicit S3/Azure credentials in parameters (user-provided) - /// 2. Auto-provisioned credentials from the cloud provider API - /// 3. No object storage (returns null — Gitea uses local PVC) - /// - /// For Cleura: authenticates via Keystone and creates EC2 (S3) credentials. - /// For AWS: expects S3 credentials in parameters (IAM roles not yet supported). - /// For Azure: expects storage account credentials in parameters. - /// - private async Task ResolveObjectStorageAsync( - KubernetesCluster cluster, Dictionary? parameters, - string targetNamespace, CancellationToken ct) - { - // If the user selected a pre-created storage bucket, resolve its - // credentials and use it directly — no need for manual S3 fields. - - string storageBucketId = GetParam(parameters, "storageBucketId", ""); - - if (!string.IsNullOrEmpty(storageBucketId) && Guid.TryParse(storageBucketId, out Guid bucketId)) - { - StorageBucket? bucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == bucketId); - - if (bucket is not null) - { - logger.LogInformation("Using pre-created storage bucket '{BucketName}' for Gitea object storage", bucket.Name); - - return new ObjectStorageConfig( - StorageType: "s3", - Endpoint: new Uri(bucket.Endpoint).Host, - Bucket: bucket.Name, - Region: bucket.Region.ToLowerInvariant(), - AccessKey: bucket.AccessKey, - SecretKey: bucket.SecretKey, - UseSsl: true); - } - - logger.LogWarning("Storage bucket {BucketId} not found on cluster — falling back to other resolution", storageBucketId); - } - - // If the user provided explicit S3 credentials, use them regardless of provider. - - string s3Endpoint = GetParam(parameters, "s3Endpoint", ""); - string s3AccessKey = GetParam(parameters, "s3AccessKey", ""); - string s3SecretKey = GetParam(parameters, "s3SecretKey", ""); - - if (!string.IsNullOrEmpty(s3Endpoint) && !string.IsNullOrEmpty(s3AccessKey)) - { - return new ObjectStorageConfig( - StorageType: "s3", - Endpoint: s3Endpoint, - Bucket: GetParam(parameters, "s3Bucket", "gitea"), - Region: GetParam(parameters, "s3Region", "us-east-1"), - AccessKey: s3AccessKey, - SecretKey: s3SecretKey, - UseSsl: true); - } - - // If explicit Azure credentials are provided, use Azure Blob Storage. - - string azureAccountName = GetParam(parameters, "azureAccountName", ""); - string azureAccountKey = GetParam(parameters, "azureAccountKey", ""); - - if (!string.IsNullOrEmpty(azureAccountName) && !string.IsNullOrEmpty(azureAccountKey)) - { - return new ObjectStorageConfig( - StorageType: "azureblob", - Endpoint: $"{azureAccountName}.blob.core.windows.net", - Bucket: GetParam(parameters, "azureContainer", "gitea"), - Region: "", - AccessKey: azureAccountName, - SecretKey: azureAccountKey, - UseSsl: true); - } - - // No explicit credentials — try auto-provisioning based on provider. - - switch (cluster.Provider) - { - case CloudProvider.Cleura: - return await ProvisionCleuraStorageAsync(cluster, parameters, ct); - - case CloudProvider.Aws: - // AWS requires explicit credentials for now. IAM roles for - // service accounts (IRSA) could be added later. - logger.LogWarning("AWS provider detected but no S3 credentials provided — Gitea will use local storage"); - return null; - - case CloudProvider.Azure: - // Azure requires explicit storage account credentials. - logger.LogWarning("Azure provider detected but no storage credentials provided — Gitea will use local storage"); - return null; - - default: - return null; - } - } - - /// - /// Auto-provisions S3 credentials for Cleura's OpenStack-based object storage. - /// Authenticates via Keystone to get a token, then creates EC2 credentials - /// (S3 access/secret key pair). The S3 endpoint follows the pattern - /// https://s3-{region}.citycloud.com. - /// - private async Task ProvisionCleuraStorageAsync( - KubernetesCluster cluster, Dictionary? parameters, CancellationToken ct) - { - ProviderCredentials? creds = cluster.ProviderCredentials; - - if (creds?.OpenStackAuthUrl is null || creds.OpenStackUsername is null || - creds.OpenStackPassword is null || creds.OpenStackProjectId is null) - { - logger.LogWarning("Cleura provider configured but OpenStack credentials incomplete — skipping S3 auto-provisioning"); - return null; - } - - // Authenticate with Keystone to get a scoped token. - - Result authResult = await cleuraS3Client.AuthenticateKeystoneAsync( - creds.OpenStackAuthUrl, - creds.OpenStackUsername, - creds.OpenStackPassword, - creds.OpenStackUserDomainName ?? "Default", - creds.OpenStackProjectId, - ct); - - if (!authResult.IsSuccess) - { - logger.LogError("Keystone authentication failed: {Error}", authResult.Error); - return null; - } - - // Create EC2 credentials (S3 access/secret key pair). - - Result credResult = await cleuraS3Client.CreateEc2CredentialsAsync(authResult.Value!, ct); - - if (!credResult.IsSuccess) - { - logger.LogError("Failed to create S3 credentials: {Error}", credResult.Error); - return null; - } - - string s3Endpoint = CleuraS3Client.GetS3Endpoint(creds.Region); - - logger.LogInformation("Auto-provisioned Cleura S3 credentials for Gitea (endpoint: {Endpoint})", s3Endpoint); - - return new ObjectStorageConfig( - StorageType: "s3", - Endpoint: new Uri(s3Endpoint).Host, - Bucket: GetParam(parameters, "s3Bucket", "gitea"), - Region: creds.Region.ToLowerInvariant(), - AccessKey: credResult.Value!.Access, - SecretKey: credResult.Value!.Secret, - UseSsl: true); - } - - /// - /// Creates or updates a Kubernetes Secret containing the object storage - /// credentials. Gitea references these via additionalConfigFromEnvs to - /// avoid embedding secrets in Helm values. - /// - private async Task EnsureStorageSecretAsync( - Kubernetes client, string targetNamespace, ObjectStorageConfig config, CancellationToken ct) - { - Dictionary secretData = config.StorageType switch - { - "s3" => new Dictionary - { - ["access-key"] = config.AccessKey, - ["secret-key"] = config.SecretKey - }, - "azureblob" => new Dictionary - { - ["account-name"] = config.AccessKey, - ["account-key"] = config.SecretKey - }, - _ => new Dictionary() - }; - - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = "gitea-storage-secret", - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/name"] = "gitea", - ["app.kubernetes.io/component"] = "storage", - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - StringData = secretData - }; - - try - { - await client.CoreV1.ReadNamespacedSecretAsync("gitea-storage-secret", targetNamespace, cancellationToken: ct); - - // Secret exists — replace it with new credentials. - - await client.CoreV1.ReplaceNamespacedSecretAsync(secret, "gitea-storage-secret", targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(secret, targetNamespace, cancellationToken: ct); - } - } - - /// - /// Helper to extract a parameter from the dictionary with a fallback default. - /// - private static string GetParam(Dictionary? parameters, string key, string defaultValue) - { - if (parameters?.TryGetValue(key, out string? value) == true && !string.IsNullOrEmpty(value)) - { - return value; - } - - return defaultValue; - } - - private async Task EnsureNamespaceWithLabels(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "gitea-platform" - } - } - }; - - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - // Read stdout and stderr concurrently to avoid pipe buffer deadlocks. - - Task outputTask = process.StandardOutput.ReadToEndAsync(ct); - Task errorTask = process.StandardError.ReadToEndAsync(ct); - - await process.WaitForExitAsync(ct); - - string output = await outputTask; - string error = await errorTask; - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private async Task ProvisionCnpgDatabaseAsync( - Kubernetes client, - KubernetesCluster cluster, - Dictionary? parameters, - string targetNamespace, - List actions, - CancellationToken ct) - { - // If the user selected a CNPG cluster, we create the gitea database - // on it and auto-wire the connection parameters. This eliminates the - // need for the user to manually provide dbHost/dbUser/dbPassword. - - if (parameters is null || - !parameters.TryGetValue("cnpgClusterName", out string? cnpgClusterName) || - string.IsNullOrEmpty(cnpgClusterName)) - { - return; - } - - string cnpgNamespace = parameters.TryGetValue("cnpgNamespace", out string? ns) && !string.IsNullOrEmpty(ns) - ? ns - : targetNamespace; - - string dbName = parameters.TryGetValue("dbName", out string? dn) && !string.IsNullOrEmpty(dn) - ? dn - : "gitea"; - - string dbUser = parameters.TryGetValue("dbUser", out string? du) && !string.IsNullOrEmpty(du) - ? du - : "gitea"; - - string dbPassword = parameters.TryGetValue("dbPassword", out string? dp) && !string.IsNullOrEmpty(dp) - ? dp - : Guid.NewGuid().ToString("N"); - - // Create the database and role via a K8s Job running psql. - - V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - cnpgClusterName, cnpgNamespace, dbName, dbUser, dbPassword); - - await client.BatchV1.CreateNamespacedJobAsync(job, cnpgNamespace, cancellationToken: ct); - - logger.LogInformation( - "Created database provisioning Job for '{Database}' on CNPG cluster '{Cluster}' in '{Namespace}'", - dbName, cnpgClusterName, cnpgNamespace); - - // Auto-wire the connection parameters so BuildValues produces correct config. - - string primaryHost = CnpgDatabaseProvisioner.GetPrimaryHost(cnpgClusterName, cnpgNamespace); - parameters["dbHost"] = primaryHost; - parameters["dbUser"] = dbUser; - parameters["dbPassword"] = dbPassword; - parameters["dbName"] = dbName; - - // Create a Kubernetes Secret containing the database credentials. - // Gitea can reference this secret instead of having credentials in - // plain text in Helm values. - - V1Secret credentialsSecret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - cnpgClusterName, targetNamespace, primaryHost, dbName, dbUser, dbPassword); - - try - { - await client.CoreV1.ReadNamespacedSecretAsync(credentialsSecret.Metadata.Name, targetNamespace, cancellationToken: ct); - await client.CoreV1.ReplaceNamespacedSecretAsync(credentialsSecret, credentialsSecret.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(credentialsSecret, targetNamespace, cancellationToken: ct); - } - - // Store the credentials in the vault for audit, rotation, and recovery. - // This follows the same pattern as HarborInstaller — best-effort storage - // that doesn't fail the install if the vault is unavailable. - - await StoreCredentialsInVaultAsync( - cluster.TenantId, cluster.Id, primaryHost, dbName, dbUser, dbPassword, ct); - - actions.Add($"Created database '{dbName}' on CNPG cluster '{cnpgClusterName}' ({primaryHost})"); - } - - /// - /// Resolves a Redis cluster name to a service endpoint. When the user - /// selects a Redis cluster from the dropdown, we convert it to the - /// in-cluster service address so Gitea can connect to it. - /// - /// Redis clusters provisioned by the platform follow the naming convention: - /// {clusterName}-headless.{namespace}.svc.cluster.local for Sentinel-enabled - /// clusters, or {clusterName}-master.{namespace}.svc.cluster.local for - /// standalone clusters. - /// - private static void ResolveRedisHost( - KubernetesCluster cluster, Dictionary? parameters, List actions) - { - if (parameters is null || - !parameters.TryGetValue("redisClusterName", out string? redisClusterName) || - string.IsNullOrEmpty(redisClusterName)) - { - return; - } - - // The Redis cluster's service endpoint follows the operator's naming - // convention. We look for a matching provisioned Redis cluster to - // determine the correct namespace and endpoint. - - string redisNamespace = "redis"; - string redisHost = $"{redisClusterName}-headless.{redisNamespace}.svc.cluster.local"; - - parameters["redisHost"] = redisHost; - actions.Add($"Resolved Redis cluster '{redisClusterName}' → {redisHost}"); - } - - /// - /// Auto-detects Harbor if installed on the cluster. When no explicit - /// harborRegistryUrl is provided, checks the cluster's installed - /// components for a Harbor instance and uses its domain. - /// - private static void AutoDetectHarborUrl( - KubernetesCluster cluster, Dictionary? parameters, List actions) - { - if (parameters is null) - { - return; - } - - // If the user already provided a Harbor URL, respect their choice. - - if (parameters.TryGetValue("harborRegistryUrl", out string? existing) && !string.IsNullOrEmpty(existing)) - { - return; - } - - // Check if Harbor is installed on this cluster by looking at the - // component status. If found, extract the domain from its configuration. - - ClusterComponent? harborComponent = cluster.Components - .FirstOrDefault(c => c.ComponentName == "harbor" && c.Status == ComponentStatus.Installed); - - if (harborComponent?.Configuration?.TryGetValue("harborDomain", out string? harborDomain) == true - && !string.IsNullOrEmpty(harborDomain)) - { - parameters["harborRegistryUrl"] = harborDomain; - actions.Add($"Auto-detected Harbor registry at {harborDomain}"); - } - } - - /// - /// Creates a Kubernetes Secret containing OIDC credentials for Gitea. - /// The Gitea admin API is used post-install to register the authentication - /// source. Credentials are stored in a Secret so they can be referenced - /// by Gitea's configuration without plain text exposure. - /// - private async Task EnsureOidcSecretAsync( - Kubernetes client, string targetNamespace, Dictionary parameters, CancellationToken ct) - { - string providerName = GetParam(parameters, "oidcProviderName", "Keycloak"); - string discoveryUrl = GetParam(parameters, "oidcDiscoveryUrl", ""); - string clientId = GetParam(parameters, "oidcClientId", ""); - string clientSecret = GetParam(parameters, "oidcClientSecret", ""); - string scopes = GetParam(parameters, "oidcScopes", "openid profile email"); - - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = "gitea-oidc-secret", - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/name"] = "gitea", - ["app.kubernetes.io/component"] = "oidc", - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - StringData = new Dictionary - { - ["provider-name"] = providerName, - ["discovery-url"] = discoveryUrl, - ["client-id"] = clientId, - ["client-secret"] = clientSecret, - ["scopes"] = scopes - } - }; - - try - { - await client.CoreV1.ReadNamespacedSecretAsync("gitea-oidc-secret", targetNamespace, cancellationToken: ct); - await client.CoreV1.ReplaceNamespacedSecretAsync(secret, "gitea-oidc-secret", targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(secret, targetNamespace, cancellationToken: ct); - } - - logger.LogInformation("Created OIDC secret for Gitea in {Namespace} (provider: {Provider})", targetNamespace, providerName); - } - - /// - /// Stores the generated database credentials in the vault so they're - /// persisted, auditable, and recoverable. The vault path follows the - /// infrastructure scoping convention: - /// infrastructure/{clusterId}/gitea/database - /// - /// Each credential field is stored as a separate secret so they can be - /// individually referenced or read back later. - /// If the vault is unavailable, we log a warning but don't fail — the - /// K8s Secret in the Gitea namespace is the primary credential source. - /// - private async Task StoreCredentialsInVaultAsync( - Guid tenantId, Guid clusterId, - string host, string database, string username, string password, - CancellationToken ct) - { - try - { - HttpClient secretsClient = httpClientFactory.CreateClient("SecretsApi"); - string basePath = $"infrastructure/{clusterId}/gitea/database"; - - Dictionary credentials = new() - { - ["host"] = host, - ["database"] = database, - ["username"] = username, - ["password"] = password - }; - - foreach (KeyValuePair credential in credentials) - { - using StringContent content = new( - JsonSerializer.Serialize(new { Value = credential.Value }), - Encoding.UTF8, - "application/json"); - - HttpResponseMessage response = await secretsClient.PostAsync( - $"/api/vault/{tenantId}/secrets/{basePath}/{credential.Key}", content, ct); - - if (!response.IsSuccessStatusCode) - { - logger.LogWarning( - "Failed to store Gitea DB credential '{Key}' in vault (HTTP {Status})", - credential.Key, response.StatusCode); - } - } - - logger.LogInformation( - "Stored Gitea database credentials in vault at '{BasePath}'", basePath); - } - catch (Exception ex) - { - // Vault storage is best-effort — the K8s Secret is the primary source. - - logger.LogWarning(ex, - "Could not store Gitea database credentials in vault — vault may be unavailable"); - } - } - - 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); - } -} - -/// -/// Encapsulates the resolved object storage configuration for Gitea. -/// Works across all supported cloud providers — Cleura (OpenStack S3), -/// AWS S3, Azure Blob Storage, GCP Cloud Storage. -/// -public record ObjectStorageConfig( - string StorageType, - string Endpoint, - string Bucket, - string Region, - string AccessKey, - string SecretKey, - bool UseSsl); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Grafana/GrafanaCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Grafana/GrafanaCheck.cs deleted file mode 100644 index f0e4a62..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Grafana/GrafanaCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class GrafanaCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "grafana"; - public string DisplayName => "Grafana Dashboards"; - - public GrafanaCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: Grafana pods running - - List 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 - { - ["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> FindGrafanaPods(Kubernetes client, CancellationToken ct) - { - List 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 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Grafana/GrafanaInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Grafana/GrafanaInstaller.cs deleted file mode 100644 index 39d25e7..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Grafana/GrafanaInstaller.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Grafana; - -/// -/// 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 -/// -public class GrafanaInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string DefaultNamespace = "monitoring"; - private const string HelmRepo = "https://prometheus-community.github.io/helm-charts"; - - public string ComponentName => "grafana"; - - public GrafanaInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List 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 { "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 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 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; - } - - /// - /// 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 - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-grafana-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// 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. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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()); - } - - // 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborCheck.cs deleted file mode 100644 index 147cf6a..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborCheck.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class HarborCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - private const string DefaultNamespace = "harbor"; - - public string ComponentName => "harbor"; - public string DisplayName => "Harbor (Container Registry & Helm Charts)"; - - public HarborCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: Look for Harbor pods in the expected namespace. - // Harbor has several components: core, registry, portal, database, redis, trivy, jobservice. - - Dictionary 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 unhealthyComponents = new(); - - foreach (KeyValuePair 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 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); - } - - /// - /// Finds all Harbor-related pods and groups them by component, reporting the phase - /// of each. Harbor labels its pods with app.kubernetes.io/component. - /// - private async Task> CheckHarborComponents(Kubernetes client, CancellationToken ct) - { - Dictionary 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; - } - - /// - /// Looks for an Ingress resource in the Harbor namespace to determine the registry endpoint. - /// - private async Task 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; - } - - /// - /// 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). - /// - private async Task 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborEndpoints.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborEndpoints.cs deleted file mode 100644 index 9532876..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborEndpoints.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -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.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.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 projects = await ListProjectsAsync(harborDomain, adminPassword, ct); - return Results.Ok(ApiResponse>.Ok(projects)); - } - catch (Exception ex) - { - return Results.Json( - ApiResponse.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.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.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.Ok(result)); - } - catch (Exception ex) - { - return Results.Json( - ApiResponse.Fail($"Failed to create pull secret: {ex.Message}"), - statusCode: 502); - } - }); - } - - // ─── Harbor API Operations ──────────────────────────────────────────── - - private static async Task> ListProjectsAsync( - string harborDomain, string adminPassword, CancellationToken ct) - { - List 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 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 - { - [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 - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "harbor" - } - }, - Type = "kubernetes.io/dockerconfigjson", - Data = new Dictionary - { - [".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); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborInstaller.cs deleted file mode 100644 index 9298f18..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Harbor/HarborInstaller.cs +++ /dev/null @@ -1,1547 +0,0 @@ -using System.Net.Http.Headers; -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; - -/// -/// Installs and configures Harbor container registry on the cluster. Harbor is the -/// platform's central registry for container images and Helm charts, and critically -/// serves as a pull-through cache for all upstream registries. -/// -/// **Install** deploys Harbor via Helm and creates proxy cache projects for the -/// configured upstream registries. The cluster can then pull all images through -/// Harbor, gaining: -/// - Faster pulls (local cache) -/// - Resilience against upstream outages -/// - Vulnerability scanning on all images -/// - Network isolation (only Harbor talks to the internet) -/// -/// **Configure** manages Harbor post-install: -/// - Add/remove proxy cache projects for new upstream registries -/// - Create private projects for tenant image isolation -/// - Set up Kyverno policies to rewrite image references through Harbor -/// - Enable/configure vulnerability scanning -/// -/// Configuration keys (Install): -/// - "harborDomain": Domain for Harbor (e.g., "registry.internal.corp.com") -/// - "adminPassword": Initial admin password -/// - "storageClass": StorageClass for PVCs -/// - "storageSize": Registry storage size (default: "50Gi") -/// - "cacheRegistries": Comma-separated upstream registries to cache (default: "docker.io,ghcr.io,quay.io") -/// - "enableChartMuseum": "true" to enable Helm chart hosting -/// - "enableTLS": "true" for TLS (default: "true") -/// - "tlsIssuer": ClusterIssuer name for cert-manager TLS -/// - "databaseType": "internal" or "external" (default: "internal") -/// - "externalDatabaseUrl": Connection string if databaseType=external -/// -/// Configuration keys (Configure): -/// - "addProxyCache": Upstream registry URL to add as proxy cache -/// - "proxyCacheProjectName": Name for the new proxy cache project -/// - "setDefaultCache": "true" to create Kyverno policy rewriting image refs -/// - "harborDomain": Domain for Harbor (needed for rewrite rules) -/// - "cacheStrategy": "kyverno-rewrite" (default) -/// - "createProject": Name of private project to create -/// - "projectVisibility": "private" or "public" (default: "private") -/// - "createPullSecret": "true" to create image pull secret -/// - "targetNamespace": Namespace for pull secret -/// - "enableScanOnPush": "true" to enable auto-scanning -/// - "severityThreshold": Minimum severity to flag (Low/Medium/High/Critical) -/// - "scanSchedule": Cron expression for periodic scanning -/// -public class HarborInstaller : IComponentInstaller -{ - private readonly ILogger logger; - private readonly IHttpClientFactory httpClientFactory; - - private const string DefaultVersion = "1.16.0"; - private const string DefaultNamespace = "harbor"; - private const string HelmRepo = "https://helm.goharbor.io"; - private const string DefaultCacheRegistries = "docker.io,ghcr.io,quay.io,registry.k8s.io,gcr.io"; - - /// - /// Maps each upstream registry to the platform services that pull images from it. - /// Used by wireAllServices to report which services are affected. - /// - private static readonly Dictionary> PlatformServicesByRegistry = new() - { - ["docker.io"] = new() { "MinIO", "Istio", "Grafana", "RabbitMQ", "Harbor" }, - ["ghcr.io"] = new() { "CloudNativePG", "Kyverno", "External Secrets" }, - ["quay.io"] = new() { "cert-manager", "trust-manager", "Prometheus", "Redis Operator" }, - ["registry.k8s.io"] = new() { "CoreDNS", "metrics-server" }, - ["gcr.io"] = new() { "Istio release images" } - }; - - /// - /// Maps each upstream registry to its Harbor proxy cache project name. - /// - private static readonly Dictionary RegistryCacheProjectNames = new() - { - ["docker.io"] = "dockerhub-cache", - ["ghcr.io"] = "ghcr-cache", - ["quay.io"] = "quay-cache", - ["registry.k8s.io"] = "k8s-cache", - ["gcr.io"] = "gcr-cache" - }; - - public string ComponentName => "harbor"; - - public HarborInstaller(ILogger logger, IHttpClientFactory httpClientFactory) - { - this.logger = logger; - this.httpClientFactory = httpClientFactory; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-harbor-{Guid.NewGuid()}.kubeconfig"); - - try - { - Kubernetes client = BuildClient(cluster); - - // Prerequisite: check that an ingress controller is available. - - bool hasIngress = await IngressControllerExists(client, ct); - - if (!hasIngress) - { - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: "Harbor requires an ingress controller but none was detected", - Actions: new List { "Prerequisite check failed: no ingress controller found" }); - } - - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - // Read parameters. - - string harborDomain = "harbor.local"; - - if (options.Parameters?.TryGetValue("harborDomain", out string? domainVal) == true - && !string.IsNullOrEmpty(domainVal)) - { - harborDomain = domainVal; - } - - string adminPassword = "Harbor12345"; - - if (options.Parameters?.TryGetValue("adminPassword", out string? passVal) == true - && !string.IsNullOrEmpty(passVal)) - { - adminPassword = passVal; - } - - string storageClass = "default"; - - if (options.Parameters?.TryGetValue("storageClass", out string? scVal) == true - && !string.IsNullOrEmpty(scVal)) - { - storageClass = scVal; - } - - string storageSize = "50Gi"; - - if (options.Parameters?.TryGetValue("storageSize", out string? sizeVal) == true - && !string.IsNullOrEmpty(sizeVal)) - { - storageSize = sizeVal; - } - - string cacheRegistries = DefaultCacheRegistries; - - if (options.Parameters?.TryGetValue("cacheRegistries", out string? cacheVal) == true - && !string.IsNullOrEmpty(cacheVal)) - { - cacheRegistries = cacheVal; - } - - bool enableChartMuseum = options.Parameters?.TryGetValue("enableChartMuseum", out string? chartVal) == true - && chartVal == "true"; - - bool enableTLS = options.Parameters?.TryGetValue("enableTLS", out string? tlsVal) != true - || tlsVal != "false"; - - string? tlsIssuer = null; - - if (options.Parameters?.TryGetValue("tlsIssuer", out string? issuerVal) == true - && !string.IsNullOrEmpty(issuerVal)) - { - tlsIssuer = issuerVal; - } - - // Resolve S3 storage bucket if the user selected one from the Storage tab. - // When a bucket is selected, Harbor stores registry images in S3 instead of PVC. - - StorageBucket? registryBucket = null; - - if (options.Parameters?.TryGetValue("storageBucketId", out string? bucketIdVal) == true - && !string.IsNullOrEmpty(bucketIdVal) && Guid.TryParse(bucketIdVal, out Guid bucketId)) - { - registryBucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == bucketId); - - if (registryBucket is not null) - { - logger.LogInformation("Using pre-created storage bucket '{BucketName}' for Harbor registry storage", registryBucket.Name); - } - } - - // Ensure the Harbor namespace exists. - - await EnsureNamespace(client, targetNamespace, ct); - actions.Add($"Ensured namespace '{targetNamespace}'"); - - // If Harbor was installed outside Helm (e.g., via Terraform), existing - // resources won't have Helm ownership labels. Adopt them so Helm can - // manage them during upgrade --install. - - await AdoptExistingResourcesForHelm(client, targetNamespace, "harbor", ct); - actions.Add("Adopted existing resources for Helm management"); - - // If a CNPG cluster is specified, provision the Harbor database on it - // instead of using the embedded PostgreSQL sub-chart. - - string? cnpgPrimaryHost = null; - string? cnpgDbUser = null; - string? cnpgDbPassword = null; - string? cnpgCredentialsSecret = null; - - if (options.Parameters?.TryGetValue("cnpgClusterName", out string? cnpgClusterName) == true - && !string.IsNullOrEmpty(cnpgClusterName)) - { - string cnpgNamespace = options.Parameters.TryGetValue("cnpgNamespace", out string? cnpgNs) && !string.IsNullOrEmpty(cnpgNs) - ? cnpgNs - : targetNamespace; - - cnpgDbUser = "harbor"; - cnpgDbPassword = Guid.NewGuid().ToString("N"); - cnpgPrimaryHost = CnpgDatabaseProvisioner.GetPrimaryHost(cnpgClusterName, cnpgNamespace); - - // Step 1: Create the database on the CNPG cluster via a K8s Job. - - V1Job dbJob = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - cnpgClusterName, cnpgNamespace, "harbor", cnpgDbUser, cnpgDbPassword); - - await client.BatchV1.CreateNamespacedJobAsync(dbJob, cnpgNamespace, cancellationToken: ct); - - logger.LogInformation( - "Created database provisioning Job for 'harbor' on CNPG cluster '{Cluster}' in '{Namespace}'", - cnpgClusterName, cnpgNamespace); - - // Step 2: Create a K8s Secret with the database credentials in the - // Harbor namespace so Harbor can reference it via existingSecret. - - cnpgCredentialsSecret = "harbor-cnpg-credentials"; - - V1Secret credSecret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - cnpgCredentialsSecret, targetNamespace, cnpgPrimaryHost, "harbor", cnpgDbUser, cnpgDbPassword); - - try - { - await client.CoreV1.ReplaceNamespacedSecretAsync(credSecret, cnpgCredentialsSecret, targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(credSecret, targetNamespace, cancellationToken: ct); - } - - // Step 3: Store the credentials in the vault for audit and recovery. - // If the vault is unavailable, we log a warning but don't fail the - // install — the K8s Secret is the primary source for Harbor. - - await StoreCredentialsInVaultAsync(cluster.TenantId, cluster.Id, - cnpgPrimaryHost, "harbor", cnpgDbUser, cnpgDbPassword, ct); - - actions.Add($"Created database 'harbor' on CNPG cluster '{cnpgClusterName}' ({cnpgPrimaryHost})"); - actions.Add("Stored database credentials in Kubernetes Secret and vault"); - } - - // Build Helm set flags for the installation. - - List setFlags = BuildHelmSetFlags( - harborDomain, adminPassword, storageClass, storageSize, - enableChartMuseum, enableTLS, tlsIssuer, registryBucket, - cnpgPrimaryHost, cnpgDbUser, cnpgDbPassword, cnpgCredentialsSecret); - - // Deploy Harbor via Helm. - - await RunCommand("helm", - $"upgrade --install harbor harbor --repo {HelmRepo} --version {version} " + - $"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"{string.Join(" ", setFlags)} --wait --timeout 15m", - ct); - actions.Add($"Helm install harbor v{version}"); - - // If TLS is configured via ClusterIssuer, report it. - - if (tlsIssuer is not null) - { - actions.Add($"Configured TLS via ClusterIssuer '{tlsIssuer}'"); - actions.Add($"Created Ingress for {harborDomain} with TLS"); - } - - // Create proxy cache projects for each upstream registry. - // Harbor's API is available once the pods are ready. - - List registries = cacheRegistries.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); - - foreach (string registry in registries) - { - string projectName = BuildCacheProjectName(registry); - await CreateProxyCacheProject(harborDomain, adminPassword, projectName, registry, ct); - actions.Add($"Created proxy cache project '{projectName}' → {registry}"); - } - - actions.Add($"Harbor registry available at {harborDomain}"); - - logger.LogInformation("Harbor {Version} installed on cluster {Cluster} at {Domain}", - version, cluster.Name, harborDomain); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Harbor {version} installed with pull-through cache for {cacheRegistries}", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to install Harbor on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to install Harbor: {ex.Message}", - Actions: actions); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Reconfigures a running Harbor instance. Supports adding proxy caches, - /// creating projects, configuring cluster-wide pull-through via Kyverno, - /// and enabling vulnerability scanning. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Route to the appropriate configuration action. - - if (configuration.Values.TryGetValue("wireAllServices", out string? wireAll) - && wireAll == "true") - { - return await ConfigureWireAllServices(client, cluster, configuration, actions, ct); - } - - if (configuration.Values.TryGetValue("addProxyCache", out string? upstreamRegistry) - && !string.IsNullOrEmpty(upstreamRegistry)) - { - return await ConfigureAddProxyCache(client, cluster, configuration, upstreamRegistry, actions, ct); - } - - if (configuration.Values.TryGetValue("setDefaultCache", out string? setCache) - && setCache == "true") - { - return await ConfigureDefaultCachePolicy(client, configuration, actions, ct); - } - - if (configuration.Values.TryGetValue("createProject", out string? projectName) - && !string.IsNullOrEmpty(projectName)) - { - return await ConfigureCreateProject(client, cluster, configuration, projectName, actions, ct); - } - - if (configuration.Values.TryGetValue("enableScanOnPush", out string? scanVal) - && scanVal == "true") - { - return await ConfigureScanning(configuration, actions, ct); - } - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: "No recognized configuration action specified", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to configure Harbor on cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to configure Harbor: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - } - - /// - /// Uninstalls Harbor from the cluster by removing the Helm release. - /// Warning: all container images and Helm charts stored in Harbor will be lost. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-harbor-rm-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - await RunCommand("helm", - $"uninstall harbor --namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--wait --timeout 5m", - ct); - actions.Add("Helm uninstall harbor"); - - logger.LogInformation("Harbor uninstalled from cluster {Cluster}", cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Harbor uninstalled successfully", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to uninstall Harbor from cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to uninstall Harbor: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - // ─── Configuration Actions ──────────────────────────────────────────────── - - private async Task ConfigureAddProxyCache( - Kubernetes client, KubernetesCluster cluster, ComponentConfiguration configuration, - string upstreamRegistry, List actions, CancellationToken ct) - { - // Determine the project name for this cache. - - string projectName = BuildCacheProjectName(upstreamRegistry); - - if (configuration.Values.TryGetValue("proxyCacheProjectName", out string? customName) - && !string.IsNullOrEmpty(customName)) - { - projectName = customName; - } - - // Get Harbor admin credentials to call the API. - - string harborDomain = GetHarborDomain(configuration); - string adminPassword = GetAdminPassword(configuration); - - await CreateProxyCacheProject(harborDomain, adminPassword, projectName, upstreamRegistry, ct); - actions.Add($"Created proxy cache project '{projectName}' → {upstreamRegistry}"); - - // Report all current cache projects. - - List allCaches = await ListProxyCacheProjects(harborDomain, adminPassword, ct); - actions.Add($"Proxy cache now covers: {string.Join(", ", allCaches)}"); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Proxy cache project added for {upstreamRegistry}", - Actions: actions); - } - - /// - /// Wires ALL platform services to pull through Harbor. This is the comprehensive - /// setup that creates proxy cache projects for every upstream registry our services - /// use, then creates a Kyverno policy to rewrite image references cluster-wide. - /// - /// After this runs, the following services pull through Harbor: - /// - docker.io: MinIO, Istio, Grafana, Redis, RabbitMQ, Harbor - /// - ghcr.io: CloudNativePG, Kyverno, External Secrets - /// - quay.io: cert-manager, trust-manager, Prometheus - /// - registry.k8s.io: CoreDNS, metrics-server - /// - gcr.io: Istio release images - /// - private async Task ConfigureWireAllServices( - Kubernetes client, KubernetesCluster cluster, ComponentConfiguration configuration, - List actions, CancellationToken ct) - { - string harborDomain = GetHarborDomain(configuration); - string adminPassword = GetAdminPassword(configuration); - - // Step 1: Ensure proxy cache projects exist for every upstream registry. - - foreach (KeyValuePair entry in RegistryCacheProjectNames) - { - string registry = entry.Key; - string projectName = entry.Value; - string services = string.Join(", ", PlatformServicesByRegistry[registry]); - - await CreateProxyCacheProject(harborDomain, adminPassword, projectName, registry, ct); - actions.Add($"Ensured proxy cache project '{projectName}' \u2192 {registry} ({services})"); - } - - // Step 2: Create the comprehensive Kyverno policy that rewrites all image refs. - - Dictionary rewriteRules = new(); - - foreach (KeyValuePair entry in RegistryCacheProjectNames) - { - rewriteRules[entry.Key] = $"{harborDomain}/{entry.Value}"; - } - - Dictionary policy = BuildImageMirrorPolicy(rewriteRules); - - await ApplyClusterCustomObject(client, "kyverno.io", "v1", "clusterpolicies", - "harbor-image-mirror", policy, ct); - - actions.Add("Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references"); - - foreach (KeyValuePair rule in rewriteRules) - { - actions.Add($"Policy rewrites {rule.Key}/* \u2192 {rule.Value}/*"); - } - - // Count total services wired. - - int totalServices = PlatformServicesByRegistry.Values.Sum(s => s.Count); - actions.Add($"All {totalServices} platform services now pull through Harbor"); - - logger.LogInformation( - "All platform services on cluster {Cluster} wired to pull through Harbor at {Domain}", - cluster.Name, harborDomain); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "All platform services wired to pull through Harbor", - Actions: actions); - } - - private async Task ConfigureDefaultCachePolicy( - Kubernetes client, ComponentConfiguration configuration, List actions, CancellationToken ct) - { - // Create a Kyverno ClusterPolicy that mutates pod image references - // to route through Harbor proxy cache projects. - - string harborDomain = GetHarborDomain(configuration); - - // Build the Kyverno policy that rewrites image references for all registries. - - Dictionary rewriteRules = new(); - - foreach (KeyValuePair entry in RegistryCacheProjectNames) - { - rewriteRules[entry.Key] = $"{harborDomain}/{entry.Value}"; - } - - Dictionary policy = BuildImageMirrorPolicy(rewriteRules); - - await ApplyClusterCustomObject(client, "kyverno.io", "v1", "clusterpolicies", - "harbor-image-mirror", policy, ct); - - actions.Add("Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references"); - - foreach (KeyValuePair rule in rewriteRules) - { - actions.Add($"Policy rewrites {rule.Key}/* → {rule.Value}/*"); - } - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Cluster configured to use Harbor as default pull-through cache", - Actions: actions); - } - - private async Task ConfigureCreateProject( - Kubernetes client, KubernetesCluster cluster, ComponentConfiguration configuration, - string projectName, List actions, CancellationToken ct) - { - string visibility = "private"; - - if (configuration.Values.TryGetValue("projectVisibility", out string? visVal) - && !string.IsNullOrEmpty(visVal)) - { - visibility = visVal; - } - - string harborDomain = GetHarborDomain(configuration); - string adminPassword = GetAdminPassword(configuration); - - // Create the project via Harbor API. - - await CreatePrivateProject(harborDomain, adminPassword, projectName, visibility == "public", ct); - actions.Add($"Created {visibility} project '{projectName}'"); - - // Optionally create a robot account and pull secret. - - if (configuration.Values.TryGetValue("createPullSecret", out string? pullVal) - && pullVal == "true") - { - string robotName = $"robot${projectName}-pull"; - string robotToken = await CreateRobotAccount(harborDomain, adminPassword, projectName, robotName, ct); - actions.Add($"Created robot account '{robotName}' with pull access"); - - // Create the Kubernetes imagePullSecret in the target namespace. - - string targetNamespace = projectName; - - if (configuration.Values.TryGetValue("targetNamespace", out string? nsVal) - && !string.IsNullOrEmpty(nsVal)) - { - targetNamespace = nsVal; - } - - string secretName = $"harbor-pull-{projectName}"; - await CreateImagePullSecret(client, secretName, targetNamespace, harborDomain, robotName, robotToken, ct); - actions.Add($"Created image pull Secret '{secretName}' in namespace '{targetNamespace}'"); - } - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Private project '{projectName}' created", - Actions: actions); - } - - private Task ConfigureScanning( - ComponentConfiguration configuration, List actions, CancellationToken ct) - { - // Enable vulnerability scanning via Harbor API configuration. - // In practice this calls Harbor's /configurations endpoint. - - actions.Add("Enabled automatic scan on push for all projects"); - - if (configuration.Values.TryGetValue("severityThreshold", out string? severity) - && !string.IsNullOrEmpty(severity)) - { - actions.Add($"Set vulnerability severity threshold to '{severity}'"); - } - - if (configuration.Values.TryGetValue("scanSchedule", out string? schedule) - && !string.IsNullOrEmpty(schedule)) - { - actions.Add($"Configured scan schedule: daily at 02:00 UTC"); - } - - return Task.FromResult(new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Vulnerability scanning configured", - Actions: actions)); - } - - // ─── Harbor API Operations ──────────────────────────────────────────────── - - private async Task CreateProxyCacheProject( - string harborDomain, string adminPassword, string projectName, string upstreamRegistry, CancellationToken ct) - { - // Creates a proxy cache project in Harbor via the REST API. - // POST /api/v2.0/projects with registryId referencing the upstream. - - using HttpClient httpClient = CreateHarborClient(harborDomain, adminPassword); - - // First, create or get the registry endpoint. - - int registryId = await EnsureRegistryEndpoint(httpClient, upstreamRegistry, ct); - - // Then create the project with proxy cache type. - - object projectPayload = new - { - project_name = projectName, - metadata = new { @public = "true" }, - registry_id = registryId - }; - - string json = JsonSerializer.Serialize(projectPayload); - using StringContent content = new(json, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await httpClient.PostAsync("/api/v2.0/projects", content, ct); - - // 409 Conflict means it already exists — that's fine. - - if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.Conflict) - { - string error = await response.Content.ReadAsStringAsync(ct); - logger.LogWarning("Failed to create proxy cache project '{Project}': {Error}", projectName, error); - } - } - - private async Task EnsureRegistryEndpoint(HttpClient httpClient, string upstreamRegistry, CancellationToken ct) - { - // Check if registry endpoint already exists. - - HttpResponseMessage listResponse = await httpClient.GetAsync("/api/v2.0/registries", ct); - string listBody = await listResponse.Content.ReadAsStringAsync(ct); - - if (listResponse.IsSuccessStatusCode) - { - using JsonDocument doc = JsonDocument.Parse(listBody); - - foreach (JsonElement registry in doc.RootElement.EnumerateArray()) - { - if (registry.TryGetProperty("url", out JsonElement urlEl) - && urlEl.GetString()?.Contains(upstreamRegistry) == true - && registry.TryGetProperty("id", out JsonElement idEl)) - { - return idEl.GetInt32(); - } - } - } - - // Create a new registry endpoint. - - string registryUrl = upstreamRegistry.Contains("://") ? upstreamRegistry : $"https://{upstreamRegistry}"; - - object registryPayload = new - { - name = upstreamRegistry.Replace(".", "-"), - url = registryUrl, - type = "docker-hub", - insecure = false - }; - - string json = JsonSerializer.Serialize(registryPayload); - using StringContent content = new(json, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await httpClient.PostAsync("/api/v2.0/registries", content, ct); - - if (response.IsSuccessStatusCode) - { - // Get the ID from the Location header. - - string? location = response.Headers.Location?.ToString(); - - if (location is not null && int.TryParse(location.Split('/').Last(), out int id)) - { - return id; - } - } - - // Fallback: return 0 (will still attempt project creation). - - return 0; - } - - private async Task> ListProxyCacheProjects(string harborDomain, string adminPassword, CancellationToken ct) - { - List projects = new(); - - using HttpClient httpClient = CreateHarborClient(harborDomain, adminPassword); - HttpResponseMessage response = await httpClient.GetAsync("/api/v2.0/projects", ct); - - if (response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - - using JsonDocument doc = JsonDocument.Parse(body); - - foreach (JsonElement project in doc.RootElement.EnumerateArray()) - { - if (project.TryGetProperty("registry_id", out JsonElement regId) - && regId.ValueKind == JsonValueKind.Number - && regId.GetInt32() > 0 - && project.TryGetProperty("name", out JsonElement nameEl)) - { - string? name = nameEl.GetString(); - - if (name is not null) - { - projects.Add(name); - } - } - } - } - - return projects; - } - - private async Task CreatePrivateProject( - string harborDomain, string adminPassword, string projectName, bool isPublic, CancellationToken ct) - { - using HttpClient httpClient = CreateHarborClient(harborDomain, adminPassword); - - object projectPayload = new - { - project_name = projectName, - metadata = new { @public = isPublic ? "true" : "false" } - }; - - string json = JsonSerializer.Serialize(projectPayload); - using StringContent content = new(json, Encoding.UTF8, "application/json"); - - await httpClient.PostAsync("/api/v2.0/projects", content, ct); - } - - private async Task CreateRobotAccount( - 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 body = await response.Content.ReadAsStringAsync(ct); - - // Extract the token from the response. - - using JsonDocument doc = JsonDocument.Parse(body); - - if (doc.RootElement.TryGetProperty("secret", out JsonElement secretEl)) - { - return secretEl.GetString() ?? ""; - } - - return ""; - } - - private async Task CreateImagePullSecret( - 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 - { - [harborDomain] = new { auth } - } - }; - - string dockerConfigJson = JsonSerializer.Serialize(dockerConfig); - byte[] configBytes = Encoding.UTF8.GetBytes(dockerConfigJson); - - // Ensure target namespace exists. - - await EnsureNamespace(client, targetNamespace, ct); - - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "harbor" - } - }, - Type = "kubernetes.io/dockerconfigjson", - Data = new Dictionary - { - [".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); - } - } - - // ─── Kyverno Policy for Image Rewriting ─────────────────────────────────── - - private static Dictionary BuildImageMirrorPolicy(Dictionary rewriteRules) - { - // Build a Kyverno ClusterPolicy that uses mutate rules to rewrite - // container image references to go through Harbor proxy cache. - - List rules = new(); - - foreach (KeyValuePair rule in rewriteRules) - { - rules.Add(new Dictionary - { - ["name"] = $"rewrite-{rule.Key.Replace(".", "-")}", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["resources"] = new Dictionary - { - ["kinds"] = new List { "Pod" } - } - } - } - }, - ["mutate"] = new Dictionary - { - ["foreach"] = new List - { - new Dictionary - { - ["list"] = "request.object.spec.containers", - ["patchStrategicMerge"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["containers"] = new List - { - new Dictionary - { - ["(name)"] = "{{element.name}}", - ["image"] = $"{{{{regex_replace_all('^{rule.Key}/(.*)', '{{{{element.image}}}}', '{rule.Value}/$1')}}}}" - } - } - } - } - } - } - } - }); - } - - return new Dictionary - { - ["apiVersion"] = "kyverno.io/v1", - ["kind"] = "ClusterPolicy", - ["metadata"] = new Dictionary - { - ["name"] = "harbor-image-mirror", - ["annotations"] = new Dictionary - { - ["entkube.io/managed-by"] = "entkube-harbor", - ["policies.kyverno.io/title"] = "Harbor Image Mirror", - ["policies.kyverno.io/description"] = "Rewrites container image references to pull through Harbor proxy cache" - } - }, - ["spec"] = new Dictionary - { - ["validationFailureAction"] = "Audit", - ["background"] = true, - ["rules"] = rules - } - }; - } - - // ─── Helper Methods ─────────────────────────────────────────────────────── - - private static List BuildHelmSetFlags( - string harborDomain, string adminPassword, string storageClass, string storageSize, - bool enableChartMuseum, bool enableTLS, string? tlsIssuer, StorageBucket? registryBucket, - string? cnpgPrimaryHost = null, string? cnpgDbUser = null, string? cnpgDbPassword = null, - string? cnpgCredentialsSecret = null) - { - List flags = new() - { - $"--set expose.type=ingress", - $"--set expose.ingress.hosts.core={harborDomain}", - $"--set externalURL=https://{harborDomain}", - $"--set harborAdminPassword={adminPassword}", - $"--set trivy.enabled=true", - $"--set chartmuseum.enabled={enableChartMuseum.ToString().ToLowerInvariant()}" - }; - - // If a storage bucket is selected, configure S3 as the registry backend. - // Otherwise fall back to PVC-based storage. - - if (registryBucket is not null) - { - flags.Add("--set persistence.imageChartStorage.type=s3"); - flags.Add($"--set persistence.imageChartStorage.s3.bucket={registryBucket.Name}"); - flags.Add($"--set persistence.imageChartStorage.s3.region={registryBucket.Region.ToLowerInvariant()}"); - flags.Add($"--set persistence.imageChartStorage.s3.regionendpoint={registryBucket.Endpoint}"); - flags.Add($"--set persistence.imageChartStorage.s3.accesskey={registryBucket.AccessKey}"); - flags.Add($"--set persistence.imageChartStorage.s3.secretkey={registryBucket.SecretKey}"); - flags.Add("--set persistence.imageChartStorage.s3.secure=true"); - flags.Add("--set persistence.imageChartStorage.disableredirect=true"); - } - else - { - flags.Add($"--set persistence.persistentVolumeClaim.registry.storageClass={storageClass}"); - flags.Add($"--set persistence.persistentVolumeClaim.registry.size={storageSize}"); - } - - // Database configuration — use external CNPG cluster if provided, - // otherwise fall back to embedded PostgreSQL with PVC storage. - // When a credentials secret exists, Harbor reads the password from - // the secret instead of receiving it as a plain-text Helm flag. - - if (cnpgPrimaryHost is not null && cnpgDbUser is not null && cnpgDbPassword is not null) - { - flags.Add("--set database.type=external"); - flags.Add($"--set database.external.host={cnpgPrimaryHost}"); - flags.Add("--set database.external.port=5432"); - flags.Add($"--set database.external.username={cnpgDbUser}"); - - if (cnpgCredentialsSecret is not null) - { - flags.Add($"--set database.external.existingSecret={cnpgCredentialsSecret}"); - } - else - { - flags.Add($"--set database.external.password={cnpgDbPassword}"); - } - - flags.Add("--set database.external.coreDatabase=harbor"); - flags.Add("--set database.external.sslmode=require"); - } - else - { - flags.Add($"--set persistence.persistentVolumeClaim.database.storageClass={storageClass}"); - } - - flags.Add($"--set persistence.persistentVolumeClaim.redis.storageClass={storageClass}"); - - if (enableTLS && tlsIssuer is not null) - { - flags.Add($"--set expose.tls.enabled=true"); - flags.Add($"--set expose.ingress.annotations.cert-manager\\.io/cluster-issuer={tlsIssuer}"); - } - else if (enableTLS) - { - flags.Add($"--set expose.tls.enabled=true"); - flags.Add($"--set expose.tls.certSource=auto"); - } - - return flags; - } - - private static string BuildCacheProjectName(string registry) - { - // Convert registry domain to a friendly project name. - // docker.io → dockerhub-cache, ghcr.io → ghcr-cache, quay.io → quay-cache - - string name = registry.Replace(".", "-").Replace("/", "-"); - - if (registry == "docker.io") - { - name = "dockerhub"; - } - else if (registry.StartsWith("registry.")) - { - name = registry.Replace("registry.", "").Replace(".", "-"); - } - else - { - name = registry.Split('.')[0]; - } - - return $"{name}-cache"; - } - - private static string GetHarborDomain(ComponentConfiguration configuration) - { - if (configuration.Values.TryGetValue("harborDomain", out string? domain) - && !string.IsNullOrEmpty(domain)) - { - return domain; - } - - return "harbor.local"; - } - - private static string GetAdminPassword(ComponentConfiguration configuration) - { - if (configuration.Values.TryGetValue("adminPassword", out string? pass) - && !string.IsNullOrEmpty(pass)) - { - return pass; - } - - return "Harbor12345"; - } - - 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; - } - - private async Task IngressControllerExists(Kubernetes client, CancellationToken ct) - { - // Check for IngressClass resources — if any exist, an ingress controller is present. - - try - { - V1IngressClassList ingressClasses = await client.NetworkingV1.ListIngressClassAsync(cancellationToken: ct); - return ingressClasses.Items.Count > 0; - } - catch - { - return false; - } - } - - private async Task EnsureNamespace(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. - - string patchJson = System.Text.Json.JsonSerializer.Serialize(new - { - metadata = new - { - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "harbor", - ["policies.kyverno.io/ignore"] = "true" - } - } - }); - - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespaceAsync(patch, namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "harbor", - ["policies.kyverno.io/ignore"] = "true" - } - } - }; - - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - /// - /// When Harbor was originally installed outside Helm, its resources lack Helm - /// ownership metadata. This patches all resources in the namespace to add the - /// labels/annotations Helm requires to adopt them into its release management. - /// - 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 - { - ["app.kubernetes.io/managed-by"] = "Helm" - }, - annotations = new Dictionary - { - ["meta.helm.sh/release-name"] = releaseName, - ["meta.helm.sh/release-namespace"] = targetNamespace - } - } - }); - - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - // Patch ServiceAccounts. - - try - { - V1ServiceAccountList serviceAccounts = await client.CoreV1.ListNamespacedServiceAccountAsync(targetNamespace, cancellationToken: ct); - - foreach (V1ServiceAccount sa in serviceAccounts.Items) - { - if (sa.Metadata.Name == "default") - { - continue; - } - - try - { - await client.CoreV1.PatchNamespacedServiceAccountAsync(patch, sa.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to adopt ServiceAccount {Name}", sa.Metadata.Name); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list ServiceAccounts in {Namespace}", targetNamespace); - } - - // Patch Deployments. - - try - { - V1DeploymentList deployments = await client.AppsV1.ListNamespacedDeploymentAsync(targetNamespace, cancellationToken: ct); - - foreach (V1Deployment deployment in deployments.Items) - { - try - { - await client.AppsV1.PatchNamespacedDeploymentAsync(patch, deployment.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to adopt Deployment {Name}", deployment.Metadata.Name); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list Deployments in {Namespace}", targetNamespace); - } - - // Patch StatefulSets (Harbor uses StatefulSets for database/redis). - - try - { - V1StatefulSetList statefulSets = await client.AppsV1.ListNamespacedStatefulSetAsync(targetNamespace, cancellationToken: ct); - - foreach (V1StatefulSet sts in statefulSets.Items) - { - try - { - await client.AppsV1.PatchNamespacedStatefulSetAsync(patch, sts.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to adopt StatefulSet {Name}", sts.Metadata.Name); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list StatefulSets in {Namespace}", targetNamespace); - } - - // Patch Services. - - try - { - V1ServiceList services = await client.CoreV1.ListNamespacedServiceAsync(targetNamespace, cancellationToken: ct); - - foreach (V1Service service in services.Items) - { - if (service.Metadata.Name == "kubernetes") - { - continue; - } - - try - { - await client.CoreV1.PatchNamespacedServiceAsync(patch, service.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to adopt Service {Name}", service.Metadata.Name); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list Services in {Namespace}", targetNamespace); - } - - // Patch Secrets and ConfigMaps. - - try - { - V1SecretList secrets = await client.CoreV1.ListNamespacedSecretAsync(targetNamespace, cancellationToken: ct); - - foreach (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); - } - - try - { - V1ConfigMapList configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(targetNamespace, cancellationToken: ct); - - foreach (V1ConfigMap cm in configMaps.Items) - { - try - { - await client.CoreV1.PatchNamespacedConfigMapAsync(patch, cm.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to adopt ConfigMap {Name}", cm.Metadata.Name); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list ConfigMaps in {Namespace}", targetNamespace); - } - - // Patch Ingresses. - - try - { - V1IngressList ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(targetNamespace, cancellationToken: ct); - - foreach (V1Ingress ingress in ingresses.Items) - { - try - { - await client.NetworkingV1.PatchNamespacedIngressAsync(patch, ingress.Metadata.Name, targetNamespace, cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to adopt Ingress {Name}", ingress.Metadata.Name); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list Ingresses in {Namespace}", targetNamespace); - } - } - - private static async Task ApplyClusterCustomObject( - Kubernetes client, string group, string version, string plural, string name, - Dictionary 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 async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } - - /// - /// Stores the generated database credentials in the vault so they're - /// persisted, auditable, and recoverable. The vault path follows the - /// infrastructure scoping convention: - /// infrastructure/{tenantId}/{clusterId}/harbor/database - /// - /// Each credential field is stored as a separate secret so they can be - /// individually referenced by ExternalSecret CRs or read back later. - /// If the vault is unavailable, we log a warning but don't fail — the - /// K8s Secret in the Harbor namespace is the primary credential source. - /// - private async Task StoreCredentialsInVaultAsync( - Guid tenantId, Guid clusterId, - string host, string database, string username, string password, - CancellationToken ct) - { - try - { - HttpClient secretsClient = httpClientFactory.CreateClient("SecretsApi"); - string basePath = $"infrastructure/{clusterId}/harbor/database"; - - // Store each credential field as a separate vault secret. - // This matches the ExternalSecret pattern where each K8s Secret - // key maps to one vault path. - - Dictionary credentials = new() - { - ["host"] = host, - ["database"] = database, - ["username"] = username, - ["password"] = password - }; - - foreach (KeyValuePair credential in credentials) - { - using StringContent content = new( - JsonSerializer.Serialize(new { Value = credential.Value }), - Encoding.UTF8, - "application/json"); - - HttpResponseMessage response = await secretsClient.PostAsync( - $"/api/vault/{tenantId}/secrets/{basePath}/{credential.Key}", content, ct); - - if (!response.IsSuccessStatusCode) - { - logger.LogWarning( - "Failed to store Harbor DB credential '{Key}' in vault (HTTP {Status})", - credential.Key, response.StatusCode); - } - } - - logger.LogInformation( - "Stored Harbor database credentials in vault at '{BasePath}'", basePath); - } - catch (Exception ex) - { - // Vault storage is best-effort — the K8s Secret is the primary source. - // Don't fail the install because the vault is unreachable. - - logger.LogWarning(ex, - "Could not store Harbor database credentials in vault — vault may be unavailable"); - } - } - - /// - /// Queries the Harbor Helm repository for all available chart versions. - /// Adds the repo (idempotent), updates it, then searches for versions - /// and returns them newest-first. This lets the UI present a version - /// picker when the user wants to upgrade Harbor. - /// - public async Task> GetAvailableVersionsAsync(CancellationToken ct = default) - { - try - { - // Ensure the Harbor Helm repo is registered and up-to-date. - - await RunCommand("helm", $"repo add harbor {HelmRepo} --force-update", ct); - await RunCommand("helm", "repo update harbor", ct); - - // Search for all chart versions. The --versions flag lists every - // version instead of just the latest, and --output json makes - // parsing reliable. - - string json = await RunCommand("helm", "search repo harbor/harbor --versions --output json", ct); - - // Each entry has a "version" field for the chart version and - // "app_version" for the Harbor application version. We return - // chart versions since those are what Helm install/upgrade uses. - - List entries = JsonSerializer.Deserialize>(json) ?? new(); - - List versions = entries - .Where(e => !string.IsNullOrEmpty(e.version)) - .Select(e => e.version!) - .ToList(); - - return versions; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to fetch available Harbor versions from Helm repo"); - return new List(); - } - } - - private record HelmSearchEntry(string? name, string? version, string? app_version, string? description); -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressCheck.cs deleted file mode 100644 index 0057340..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressCheck.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class IngressCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "ingress"; - public string DisplayName => "Ingress Gateway Infrastructure"; - - public IngressCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task 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 { "Traefik manages its own ingress — no separate gateway infrastructure needed" }, - MissingItems: new List(), - Configuration: new DiscoveredConfiguration( - Version: null, - Namespace: null, - HelmReleaseName: null, - Values: new Dictionary { ["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 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(), - MissingItems: new List { "No ingress provider detected — install Istio or Traefik first" }); - } - - /// - /// Detects the ingress provider by checking which controller component - /// is already installed on the cluster. - /// - 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; - } - - /// - /// Checks Istio ingress gateway state using the cluster's stored component - /// data and a live Kubernetes API connection. - /// - private async Task CheckIstioGateways(KubernetesCluster cluster, CancellationToken ct) - { - Kubernetes client = BuildClient(cluster); - List istioPods = await FindIstioGatewayPods(client, "internal-ingress", ct); - - if (istioPods.Count == 0) - { - return new ComponentCheckResult( - ComponentName, - ComponentStatus.NotInstalled, - Details: new List { "Istio is installed but no gateway pods found" }, - MissingItems: new List { "Internal gateway pods not found in internal-ingress namespace" }); - } - - return await CheckIstioGatewaysWithClient(client, istioPods, ct); - } - - private async Task CheckIstioGatewaysWithClient( - Kubernetes client, List internalPods, CancellationToken ct) - { - List details = new(); - List missing = new(); - - details.AddRange(internalPods.Select(p => $"Internal gateway pod: {p}")); - - // Check external ingress gateway pods (optional). - - List 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 - { - ["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> FindIstioGatewayPods(Kubernetes client, string ns, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressCheck.cs.bak b/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressCheck.cs.bak deleted file mode 100644 index d0f17e2..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressCheck.cs.bak +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class IngressCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "ingress"; - public string DisplayName => "Ingress (Istio or Traefik)"; - - public IngressCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List 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 traefikPods = await FindTraefikPods(client, ct); - List 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); - } - - /// - /// Checks Traefik ingress state. Looks for pods, IngressClass, and - /// discovers the running configuration (replicas, version, dashboard status). - /// - private async Task CheckTraefik( - Kubernetes client, List traefikPods, - List details, List 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 - { - ["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); - } - - /// - /// Checks Istio ingress gateway state. Looks for internal/external gateways, - /// Gateway API resources, and discovers the running configuration. - /// - private async Task CheckIstio( - Kubernetes client, List internalPods, - List details, List missing, CancellationToken ct) - { - details.AddRange(internalPods.Select(p => $"Internal gateway pod: {p}")); - - // Check external ingress gateway pods (optional). - - List 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 - { - ["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> FindTraefikPods(Kubernetes client, CancellationToken ct) - { - List 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 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 IngressClassExists(Kubernetes client, string name, CancellationToken ct) - { - try - { - await client.NetworkingV1.ReadIngressClassAsync(name, cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private async Task 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 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 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> FindIstioGatewayPods(Kubernetes client, string ns, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressInstaller.cs deleted file mode 100644 index 1171094..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressInstaller.cs +++ /dev/null @@ -1,585 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Ingress; - -/// -/// 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) -/// -public class IngressInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task 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 { "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()); - } - - return await InstallIstioGatewaysAsync(cluster, options, ct); - } - - /// - /// Reconfigures the ingress gateway infrastructure. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - ComponentInstallOptions options = new( - Namespace: configuration.Namespace, - Parameters: configuration.Values); - - return await InstallAsync(cluster, options, ct); - } - - /// - /// Uninstalls the ingress gateway infrastructure. - /// - public async Task 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()); - } - - return await UninstallIstioGatewaysAsync(cluster, options, ct); - } - - // ───────────────────────────────────────────────────────────────────────── - // Istio Gateway Deployment - // ───────────────────────────────────────────────────────────────────────── - - private async Task 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 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 UninstallIstioGatewaysAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct) - { - List 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) ──── - - /// - /// 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. - /// - public static Dictionary GetInternalLbAnnotations(CloudProvider provider) - { - return provider switch - { - CloudProvider.Cleura => new Dictionary - { - ["service.beta.kubernetes.io/openstack-internal-load-balancer"] = "true" - }, - - CloudProvider.Aws => new Dictionary - { - ["service.beta.kubernetes.io/aws-load-balancer-scheme"] = "internal" - }, - - CloudProvider.Azure => new Dictionary - { - ["service.beta.kubernetes.io/azure-load-balancer-internal"] = "true" - }, - - CloudProvider.Gcp => new Dictionary - { - ["networking.gke.io/load-balancer-type"] = "Internal" - }, - - _ => new Dictionary() - }; - } - - /// - /// 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. - /// - public static string GetIstioInternalGatewayValues(CloudProvider provider) - { - Dictionary 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 - """; - } - - /// - /// Generates Helm values for the Istio external ingress gateway. The external - /// gateway is always public — no internal LB annotations are applied. - /// - 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 - """; - } - - /// - /// Builds the YAML fragment for service annotations. Returns an empty string - /// when no annotations are needed, or a properly indented annotations block. - /// - public static string BuildServiceAnnotationsYaml(Dictionary annotations) - { - if (annotations.Count == 0) - { - return ""; - } - - StringBuilder sb = new(); - sb.AppendLine(" annotations:"); - - foreach (KeyValuePair 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 gateway = new() - { - ["apiVersion"] = "gateway.networking.k8s.io/v1", - ["kind"] = "Gateway", - ["metadata"] = new Dictionary - { - ["name"] = name, - ["namespace"] = ns - }, - ["spec"] = new Dictionary - { - ["gatewayClassName"] = "istio", - ["listeners"] = new List> - { - new() - { - ["name"] = "https", - ["port"] = 443, - ["protocol"] = "HTTPS", - ["tls"] = new Dictionary - { - ["mode"] = "Terminate", - ["certificateRefs"] = new List> - { - new() - { - ["kind"] = "Secret", - ["name"] = $"{name}-tls" - } - } - }, - ["allowedRoutes"] = new Dictionary - { - ["namespaces"] = new Dictionary - { - ["from"] = "All" - } - } - }, - new() - { - ["name"] = "http", - ["port"] = 80, - ["protocol"] = "HTTP", - ["allowedRoutes"] = new Dictionary - { - ["namespaces"] = new Dictionary - { - ["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 route = new() - { - ["apiVersion"] = "gateway.networking.k8s.io/v1", - ["kind"] = "HTTPRoute", - ["metadata"] = new Dictionary - { - ["name"] = routeName, - ["namespace"] = ns - }, - ["spec"] = new Dictionary - { - ["parentRefs"] = new List> - { - new() - { - ["name"] = gatewayName, - ["sectionName"] = "http" - } - }, - ["rules"] = new List> - { - new() - { - ["filters"] = new List> - { - new() - { - ["type"] = "RequestRedirect", - ["requestRedirect"] = new Dictionary - { - ["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 - { - ["istio-injection"] = "enabled", - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressInstaller.cs.bak b/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressInstaller.cs.bak deleted file mode 100644 index bc0d785..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Ingress/IngressInstaller.cs.bak +++ /dev/null @@ -1,822 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Ingress; - -/// -/// 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) -/// -public class IngressInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - public string ComponentName => "ingress"; - - public IngressInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task 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()) - }; - } - - /// - /// Reconfigures the ingress solution. The provider is determined from the - /// configuration values — it must match what's already deployed. - /// - public async Task 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()) - }; - } - /// - /// Uninstalls the ingress solution from the cluster. Detects which provider - /// is deployed and removes the corresponding Helm releases. - /// - public async Task 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()) - }; - } - - private async Task UninstallTraefikAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct) - { - string targetNamespace = options.Namespace ?? DefaultTraefikNamespace; - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - private async Task UninstallIstioIngressAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct) - { - List 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 InstallTraefikAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct) - { - string version = options.Version ?? DefaultTraefikVersion; - string targetNamespace = options.Namespace ?? DefaultTraefikNamespace; - List 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); - } - } - } - - /// - /// Generates Helm values for Traefik deployment. When internalLoadBalancer is - /// enabled, applies provider-specific annotations so the Service gets a private LB. - /// - public static string GetTraefikValues(Dictionary? 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 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 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 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); - } - } - } - - /// - /// 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. - /// - public static Dictionary GetInternalLbAnnotations(CloudProvider provider) - { - return provider switch - { - CloudProvider.Cleura => new Dictionary - { - ["service.beta.kubernetes.io/openstack-internal-load-balancer"] = "true" - }, - - CloudProvider.Aws => new Dictionary - { - ["service.beta.kubernetes.io/aws-load-balancer-scheme"] = "internal" - }, - - CloudProvider.Azure => new Dictionary - { - ["service.beta.kubernetes.io/azure-load-balancer-internal"] = "true" - }, - - CloudProvider.Gcp => new Dictionary - { - ["networking.gke.io/load-balancer-type"] = "Internal" - }, - - _ => new Dictionary() - }; - } - - /// - /// 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. - /// - public static string GetIstioInternalGatewayValues(CloudProvider provider) - { - Dictionary 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 - """; - } - - /// - /// Generates Helm values for the Istio external ingress gateway. The external - /// gateway is always public — no internal LB annotations are applied. - /// - 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 - """; - } - - /// - /// Builds the YAML fragment for service annotations. Returns an empty string - /// when no annotations are needed, or a properly indented annotations block. - /// - private static string BuildServiceAnnotationsYaml(Dictionary annotations) - { - if (annotations.Count == 0) - { - return ""; - } - - System.Text.StringBuilder sb = new(); - sb.AppendLine(" annotations:"); - - foreach (KeyValuePair 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 gateway = new() - { - ["apiVersion"] = "gateway.networking.k8s.io/v1", - ["kind"] = "Gateway", - ["metadata"] = new Dictionary - { - ["name"] = name, - ["namespace"] = ns - }, - ["spec"] = new Dictionary - { - ["gatewayClassName"] = "istio", - ["listeners"] = new List> - { - new() - { - ["name"] = "https", - ["port"] = 443, - ["protocol"] = "HTTPS", - ["tls"] = new Dictionary - { - ["mode"] = "Terminate", - ["certificateRefs"] = new List> - { - new() - { - ["kind"] = "Secret", - ["name"] = $"{name}-tls" - } - } - }, - ["allowedRoutes"] = new Dictionary - { - ["namespaces"] = new Dictionary - { - ["from"] = "All" - } - } - }, - new() - { - ["name"] = "http", - ["port"] = 80, - ["protocol"] = "HTTP", - ["allowedRoutes"] = new Dictionary - { - ["namespaces"] = new Dictionary - { - ["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 route = new() - { - ["apiVersion"] = "gateway.networking.k8s.io/v1", - ["kind"] = "HTTPRoute", - ["metadata"] = new Dictionary - { - ["name"] = routeName, - ["namespace"] = ns - }, - ["spec"] = new Dictionary - { - ["parentRefs"] = new List> - { - new() - { - ["name"] = gatewayName, - ["sectionName"] = "http" - } - }, - ["rules"] = new List> - { - new() - { - ["filters"] = new List> - { - new() - { - ["type"] = "RequestRedirect", - ["requestRedirect"] = new Dictionary - { - ["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 - { - ["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 - { - ["istio-injection"] = "enabled", - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/InternalCA/InternalCACheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/InternalCA/InternalCACheck.cs deleted file mode 100644 index bef0f88..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/InternalCA/InternalCACheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class InternalCACheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "internal-ca"; - public string DisplayName => "Internal CA (Platform Certificate Authority)"; - - public InternalCACheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List 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 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 - { - ["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); - } - - /// - /// 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. - /// - private async Task> FindAllCAClusterIssuers(Kubernetes client, CancellationToken ct) - { - List result = new(); - - try - { - JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "cert-manager.io", - version: "v1", - plural: "clusterissuers", - cancellationToken: ct); - - if (issuers.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement issuer in items.EnumerateArray()) - { - // 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 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 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( - 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 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); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/InternalCA/InternalCAInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/InternalCA/InternalCAInstaller.cs deleted file mode 100644 index 980b805..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/InternalCA/InternalCAInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class InternalCAInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Check prerequisite: cert-manager must be installed. - - bool certManagerPresent = await CertManagerIsPresent(client, ct); - - if (!certManagerPresent) - { - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: "cert-manager is required but not installed", - Actions: new List { "Prerequisite check failed: cert-manager not found" }); - } - - // Read 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); - } - } - - /// - /// Reconfigures the internal CA. Supports updating the CA duration and - /// refreshing the trust bundle reference. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - List 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 { $"Error: {ex.Message}" }); - } - } - - /// - /// 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. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List 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 { $"Error: {ex.Message}" }); - } - } - - private async Task AdoptExistingCA( - Kubernetes client, ComponentInstallOptions options, string bundleName, List 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 issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = "selfsigned-bootstrap" - }, - ["spec"] = new Dictionary - { - ["selfSigned"] = new Dictionary() - } - }; - - 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 certificate = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "Certificate", - ["metadata"] = new Dictionary - { - ["name"] = caName, - ["namespace"] = DefaultNamespace - }, - ["spec"] = new Dictionary - { - ["isCA"] = true, - ["duration"] = duration, - ["secretName"] = secretName, - ["commonName"] = $"{caName} Root CA", - ["subject"] = new Dictionary - { - ["organizations"] = new List { organization } - }, - ["privateKey"] = new Dictionary - { - ["algorithm"] = "ECDSA", - ["size"] = 256 - }, - ["issuerRef"] = new Dictionary - { - ["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 issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = caName - }, - ["spec"] = new Dictionary - { - ["ca"] = new Dictionary - { - ["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( - "trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct); - - // Read current sources and add our secret if not already present. - - List 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(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 - { - ["secret"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "tls.crt" - } - }); - - // Patch the Bundle with the updated sources. - - Dictionary patch = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary - { - ["name"] = bundleName - }, - ["spec"] = new Dictionary - { - ["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 bundle = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary - { - ["name"] = bundleName - }, - ["spec"] = new Dictionary - { - ["sources"] = new List - { - new Dictionary - { - ["useDefaultCAs"] = true - }, - new Dictionary - { - ["secret"] = new Dictionary - { - ["name"] = secretName, - ["key"] = "tls.crt" - } - } - }, - ["target"] = new Dictionary - { - ["configMap"] = new Dictionary - { - ["key"] = "ca-certificates.crt" - } - } - } - }; - - await client.CustomObjects.CreateClusterCustomObjectAsync( - body: bundle, - group: "trust.cert-manager.io", - version: "v1alpha1", - plural: "bundles", - cancellationToken: ct); - } - } - - private async Task GetIssuerSecretName(Kubernetes client, string issuerName, CancellationToken ct) - { - try - { - JsonElement issuer = await client.CustomObjects.GetClusterCustomObjectAsync( - "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 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 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Istio/IstioCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Istio/IstioCheck.cs deleted file mode 100644 index eaa8c6f..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Istio/IstioCheck.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class IstioCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "istio"; - public string DisplayName => "Istio Service Mesh + Gateway API"; - - public IstioCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: 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 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 - { - ["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 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> FindIstiodPods(Kubernetes client, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Istio/IstioInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Istio/IstioInstaller.cs deleted file mode 100644 index b8e442f..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Istio/IstioInstaller.cs +++ /dev/null @@ -1,297 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Istio; - -/// -/// 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 -/// -public class IstioInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task 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 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 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); - } - - /// - /// Reconfigures Istio. Supports: - /// - "pilotReplicas": number of istiod replicas - /// - "enableGatewayApi": true/false for PILOT_ENABLE_GATEWAY_API - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? "istio-system"; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-istio-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls Istio from the cluster by removing istiod and istio-base - /// Helm releases in reverse order. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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); - } - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Keycloak/KeycloakCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Keycloak/KeycloakCheck.cs deleted file mode 100644 index 659923c..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Keycloak/KeycloakCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class KeycloakCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - private const string DefaultNamespace = "keycloak"; - - public string ComponentName => "keycloak"; - public string DisplayName => "Keycloak (Identity & Access Management)"; - - public KeycloakCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: Keycloak pods running in the expected namespace. - - List 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 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 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> FindKeycloakPods(Kubernetes client, CancellationToken ct) - { - List 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 DetectKeycloakCR(Kubernetes client, CancellationToken ct) - { - try - { - JsonElement result = await client.CustomObjects.ListNamespacedCustomObjectAsync( - 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 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> DetectRealms(Kubernetes client, CancellationToken ct) - { - List realms = new() { "master" }; - - try - { - // Check for KeycloakRealmImport CRs. - - JsonElement result = await client.CustomObjects.ListNamespacedCustomObjectAsync( - 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Keycloak/KeycloakInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Keycloak/KeycloakInstaller.cs deleted file mode 100644 index e7c2abd..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Keycloak/KeycloakInstaller.cs +++ /dev/null @@ -1,1203 +0,0 @@ -using System.Net.Http.Headers; -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; - -/// -/// Installs and configures Keycloak on the cluster. Keycloak is the platform's -/// identity and access management system, providing per-tenant realms with -/// configurable identity providers (BankID, OIDC, SAML) and custom theming. -/// -/// **Install** deploys: -/// - Keycloak Operator (manages Keycloak instances via CRDs) -/// - Keycloak CR (the actual instance backed by PostgreSQL) -/// - BankID plugin (optionally, as a custom provider JAR) -/// - Ingress for the login/admin UI -/// -/// **Configure** manages: -/// - createRealm: Create a new realm with display name and theme -/// - addProvider: Add identity providers (bankid, oidc, saml) to a realm -/// - updateTheme: Change logos, colors, backgrounds for a realm -/// -/// Configuration keys (Install): -/// - "keycloakDomain": Domain for the Keycloak UI (e.g., "auth.internal.corp.com") -/// - "adminPassword": Initial admin password -/// - "storageClass": StorageClass for database PVC -/// - "databaseType": "postgres" (CloudNativePG) or "internal" (embedded H2 — dev only) -/// - "externalDatabaseUrl": JDBC URL if using external DB -/// - "installBankIdPlugin": "true" to deploy the BankID identity provider plugin -/// - "bankIdPluginVersion": Version of the BankID plugin (default: "1.2.0") -/// - "tlsIssuer": ClusterIssuer for TLS certificate -/// - "replicas": Number of Keycloak pods (default: "2") -/// -/// Configuration keys (Configure - createRealm): -/// - "createRealm": Name of the realm to create -/// - "realmDisplayName": Human-readable display name -/// - "themeLogo": URL to logo image -/// - "themeBackground": URL to background image -/// - "themePrimaryColor": Hex color for primary brand color -/// - "themeSecondaryColor": Hex color for secondary/text color -/// - "themeLoginTitle": Title text on the login page -/// -/// Configuration keys (Configure - addProvider): -/// - "realmName": Target realm -/// - "addProvider": Provider type ("bankid", "oidc", "saml") -/// BankID-specific: -/// - "bankIdEnvironment": "production" or "test" -/// - "bankIdCertificateP12": Base64-encoded P12 certificate -/// - "bankIdCertificatePassword": Password for P12 -/// - "bankIdDisplayName": Button text (e.g., "Logga in med BankID") -/// OIDC-specific: -/// - "providerAlias": Identifier for the provider -/// - "providerDisplayName": Button text -/// - "oidcDiscoveryUrl": OIDC discovery endpoint -/// - "oidcClientId": Client ID -/// - "oidcClientSecret": Client Secret -/// SAML-specific: -/// - "providerAlias": Identifier for the provider -/// - "providerDisplayName": Button text -/// - "samlEntityId": IDP Entity ID -/// - "samlSsoUrl": SSO URL -/// - "samlCertificate": Base64-encoded signing certificate -/// -/// Configuration keys (Configure - updateTheme): -/// - "realmName": Target realm -/// - "updateTheme": "true" -/// - "themeLogo", "themeBackground", "themePrimaryColor", etc. -/// -public class KeycloakInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string DefaultVersion = "26.0.0"; - private const string DefaultNamespace = "keycloak"; - private const string HelmRepo = "https://charts.bitnami.com/bitnami"; // TODO: replace once official operator chart stabilizes - private const string OperatorHelmRepo = "https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/refs/heads/main/kubernetes"; - private const string DefaultBankIdPluginVersion = "1.2.0"; - private const string BankIdProductionEndpoint = "https://appapi2.bankid.com/rp/v6.0"; - private const string BankIdTestEndpoint = "https://appapi2.test.bankid.com/rp/v6.0"; - - public string ComponentName => "keycloak"; - - public KeycloakInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-keycloak-{Guid.NewGuid()}.kubeconfig"); - - try - { - Kubernetes client = BuildClient(cluster); - - // Prerequisite: CloudNativePG or external database must be available. - - string databaseType = "postgres"; - - if (options.Parameters?.TryGetValue("databaseType", out string? dbType) == true - && !string.IsNullOrEmpty(dbType)) - { - databaseType = dbType; - } - - if (databaseType == "postgres" && !await CloudNativePGAvailable(client, ct)) - { - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: "Keycloak requires a PostgreSQL database (CloudNativePG or external)", - Actions: new List { "Prerequisite check failed: no database available" }); - } - - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - // Read parameters. - - string keycloakDomain = "auth.local"; - - if (options.Parameters?.TryGetValue("keycloakDomain", out string? domainVal) == true - && !string.IsNullOrEmpty(domainVal)) - { - keycloakDomain = domainVal; - } - - string adminPassword = "admin"; - - if (options.Parameters?.TryGetValue("adminPassword", out string? passVal) == true - && !string.IsNullOrEmpty(passVal)) - { - adminPassword = passVal; - } - - int replicas = 2; - - if (options.Parameters?.TryGetValue("replicas", out string? repVal) == true - && int.TryParse(repVal, out int parsed)) - { - replicas = parsed; - } - - string? tlsIssuer = null; - - if (options.Parameters?.TryGetValue("tlsIssuer", out string? issVal) == true - && !string.IsNullOrEmpty(issVal)) - { - tlsIssuer = issVal; - } - - // Ensure namespace. - - await EnsureNamespace(client, targetNamespace, ct); - actions.Add($"Ensured namespace '{targetNamespace}'"); - - // Install Keycloak Operator via Helm / manifests. - - await RunCommand("helm", - $"upgrade --install keycloak-operator oci://quay.io/keycloak/keycloak-operator --version {version} " + - $"--namespace {targetNamespace} --kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--wait --timeout 10m", - ct); - actions.Add($"Helm install keycloak-operator v{version}"); - - // Create admin credentials Secret. - - await CreateAdminSecret(client, targetNamespace, adminPassword, ct); - - // If a CNPG cluster is specified, provision the Keycloak database on it. - // This replaces the assumption that a dedicated keycloak-db cluster exists. - - string dbHost = "keycloak-db-rw"; - string dbCredentialsSecret = "keycloak-db-credentials"; - - if (options.Parameters?.TryGetValue("cnpgClusterName", out string? cnpgClusterName) == true - && !string.IsNullOrEmpty(cnpgClusterName)) - { - string cnpgNamespace = options.Parameters.TryGetValue("cnpgNamespace", out string? cnpgNs) && !string.IsNullOrEmpty(cnpgNs) - ? cnpgNs - : targetNamespace; - - string dbUser = "keycloak"; - string dbPassword = Guid.NewGuid().ToString("N"); - - V1Job dbJob = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - cnpgClusterName, cnpgNamespace, "keycloak", dbUser, dbPassword); - - await client.BatchV1.CreateNamespacedJobAsync(dbJob, cnpgNamespace, cancellationToken: ct); - - logger.LogInformation( - "Created database provisioning Job for 'keycloak' on CNPG cluster '{Cluster}' in '{Namespace}'", - cnpgClusterName, cnpgNamespace); - - dbHost = CnpgDatabaseProvisioner.GetPrimaryHost(cnpgClusterName, cnpgNamespace); - dbCredentialsSecret = "keycloak-cnpg-credentials"; - - // Create the credentials secret in the Keycloak namespace so the CR can reference it. - - V1Secret credSecret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - dbCredentialsSecret, targetNamespace, dbHost, "keycloak", dbUser, dbPassword); - - try - { - await client.CoreV1.ReplaceNamespacedSecretAsync(credSecret, dbCredentialsSecret, targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(credSecret, targetNamespace, cancellationToken: ct); - } - - actions.Add($"Created database 'keycloak' on CNPG cluster '{cnpgClusterName}' ({dbHost})"); - } - - // Create Keycloak CR (the managed instance). - - await CreateKeycloakCR(client, targetNamespace, keycloakDomain, replicas, databaseType, tlsIssuer, dbHost, dbCredentialsSecret, ct); - actions.Add("Created Keycloak CR 'platform-keycloak'"); - actions.Add($"Keycloak available at {keycloakDomain}"); - - // Optionally install BankID plugin. - - bool installBankId = options.Parameters?.TryGetValue("installBankIdPlugin", out string? bankVal) == true - && bankVal == "true"; - - if (installBankId) - { - string bankIdVersion = DefaultBankIdPluginVersion; - - if (options.Parameters?.TryGetValue("bankIdPluginVersion", out string? biVer) == true - && !string.IsNullOrEmpty(biVer)) - { - bankIdVersion = biVer; - } - - await DeployBankIdPlugin(client, targetNamespace, bankIdVersion, ct); - actions.Add("BankID provider plugin deployed"); - } - - logger.LogInformation("Keycloak {Version} installed on cluster {Cluster} at {Domain}", - version, cluster.Name, keycloakDomain); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Keycloak {version} installed with operator", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to install Keycloak on cluster {Cluster}", cluster.Name); - actions.Add($"Error: {ex.Message}"); - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to install Keycloak: {ex.Message}", - Actions: actions); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Configures Keycloak post-install. Routes to the appropriate action - /// based on the configuration values provided. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Route: Create a new realm. - - if (configuration.Values.TryGetValue("createRealm", out string? realmName) - && !string.IsNullOrEmpty(realmName)) - { - return await ConfigureCreateRealm(client, cluster, configuration, realmName, actions, ct); - } - - // Route: Add identity provider to a realm. - - if (configuration.Values.TryGetValue("addProvider", out string? providerType) - && !string.IsNullOrEmpty(providerType)) - { - return await ConfigureAddProvider(client, cluster, configuration, providerType, actions, ct); - } - - // Route: Update realm theme. - - if (configuration.Values.TryGetValue("updateTheme", out string? updateTheme) - && updateTheme == "true") - { - return await ConfigureUpdateTheme(client, cluster, configuration, actions, ct); - } - - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: "No recognized configuration action specified", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to configure Keycloak on cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to configure Keycloak: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - } - - /// - /// Uninstalls Keycloak from the cluster by removing the operator Helm release. - /// Warning: all realms, users, and identity provider configurations will be - /// lost unless backed up externally. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-keycloak-rm-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - await RunCommand("helm", - $"uninstall keycloak-operator --namespace {targetNamespace} " + - $"--kubeconfig \"{tempKubeConfig}\" --kube-context \"{cluster.ContextName}\" " + - $"--wait --timeout 5m", - ct); - actions.Add("Helm uninstall keycloak-operator"); - - logger.LogInformation("Keycloak uninstalled from cluster {Cluster}", cluster.Name); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: "Keycloak uninstalled successfully", - Actions: actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to uninstall Keycloak from cluster {Cluster}", cluster.Name); - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Failed to uninstall Keycloak: {ex.Message}", - Actions: new List { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - // ─── Configure: Create Realm ────────────────────────────────────────────── - - private async Task ConfigureCreateRealm( - Kubernetes client, KubernetesCluster cluster, ComponentConfiguration configuration, - string realmName, List actions, CancellationToken ct) - { - // Get the Keycloak admin endpoint and credentials. - - string keycloakDomain = GetKeycloakDomain(configuration); - string adminToken = await GetAdminToken(keycloakDomain, ct); - - // Create the realm via Keycloak Admin REST API. - - string? displayName = null; - configuration.Values.TryGetValue("realmDisplayName", out displayName); - - await CreateRealmViaApi(keycloakDomain, adminToken, realmName, displayName, ct); - actions.Add($"Created realm '{realmName}'"); - - if (!string.IsNullOrEmpty(displayName)) - { - actions.Add($"Set realm display name to '{displayName}'"); - } - - // Apply theme if theme parameters are provided. - - bool hasTheme = configuration.Values.ContainsKey("themeLogo") - || configuration.Values.ContainsKey("themePrimaryColor") - || configuration.Values.ContainsKey("themeBackground"); - - if (hasTheme) - { - string themeName = $"{realmName}-theme"; - await DeployRealmTheme(client, keycloakDomain, adminToken, realmName, themeName, configuration, actions, ct); - } - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Realm '{realmName}' created with custom theme", - Actions: actions); - } - - // ─── Configure: Add Provider ────────────────────────────────────────────── - - private async Task ConfigureAddProvider( - Kubernetes client, KubernetesCluster cluster, ComponentConfiguration configuration, - string providerType, List actions, CancellationToken ct) - { - string realmName = "master"; - - if (configuration.Values.TryGetValue("realmName", out string? realm) - && !string.IsNullOrEmpty(realm)) - { - realmName = realm; - } - - string keycloakDomain = GetKeycloakDomain(configuration); - string adminToken = await GetAdminToken(keycloakDomain, ct); - - switch (providerType.ToLowerInvariant()) - { - case "bankid": - return await ConfigureAddBankId(client, keycloakDomain, adminToken, realmName, configuration, actions, ct); - - case "oidc": - return await ConfigureAddOidc(keycloakDomain, adminToken, realmName, configuration, actions, ct); - - case "saml": - return await ConfigureAddSaml(keycloakDomain, adminToken, realmName, configuration, actions, ct); - - default: - return new InstallResult( - Success: false, - ComponentName: ComponentName, - Message: $"Unknown provider type: {providerType}", - Actions: actions); - } - } - - private async Task ConfigureAddBankId( - Kubernetes client, string keycloakDomain, string adminToken, string realmName, - ComponentConfiguration configuration, List actions, CancellationToken ct) - { - // BankID requires: - // 1. The BankID plugin must be installed (check) - // 2. A service certificate (P12) stored in Keycloak's keystore - // 3. Configuration of the BankID endpoint (production or test) - - string environment = "production"; - - if (configuration.Values.TryGetValue("bankIdEnvironment", out string? envVal) - && !string.IsNullOrEmpty(envVal)) - { - environment = envVal; - } - - string bankIdEndpoint = environment == "test" ? BankIdTestEndpoint : BankIdProductionEndpoint; - - string? displayName = "BankID"; - - if (configuration.Values.TryGetValue("bankIdDisplayName", out string? dispVal) - && !string.IsNullOrEmpty(dispVal)) - { - displayName = dispVal; - } - - // Store the P12 certificate as a Kubernetes Secret for the Keycloak pod to mount. - - if (configuration.Values.TryGetValue("bankIdCertificateP12", out string? certData) - && !string.IsNullOrEmpty(certData)) - { - string? certPassword = null; - configuration.Values.TryGetValue("bankIdCertificatePassword", out certPassword); - - await StoreBankIdCertificate(client, DefaultNamespace, realmName, certData, certPassword ?? "", ct); - actions.Add("Stored BankID service certificate in Keycloak keystore"); - } - - // Configure the BankID identity provider via Keycloak Admin API. - - Dictionary providerConfig = new() - { - ["alias"] = "bankid", - ["providerId"] = "bankid", - ["enabled"] = true, - ["displayName"] = displayName, - ["config"] = new Dictionary - { - ["bankIdEndpoint"] = bankIdEndpoint, - ["environment"] = environment, - ["realmName"] = realmName - } - }; - - await CreateIdentityProvider(keycloakDomain, adminToken, realmName, providerConfig, ct); - - actions.Add($"Configured BankID identity provider in realm '{realmName}'"); - actions.Add($"BankID endpoint: {bankIdEndpoint}"); - actions.Add("BankID provider enabled as login option"); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"BankID provider added to realm '{realmName}'", - Actions: actions); - } - - private async Task ConfigureAddOidc( - string keycloakDomain, string adminToken, string realmName, - ComponentConfiguration configuration, List actions, CancellationToken ct) - { - string alias = "oidc-provider"; - - if (configuration.Values.TryGetValue("providerAlias", out string? aliasVal) - && !string.IsNullOrEmpty(aliasVal)) - { - alias = aliasVal; - } - - string? displayName = null; - configuration.Values.TryGetValue("providerDisplayName", out displayName); - - string? discoveryUrl = null; - configuration.Values.TryGetValue("oidcDiscoveryUrl", out discoveryUrl); - - string? clientId = null; - configuration.Values.TryGetValue("oidcClientId", out clientId); - - string? clientSecret = null; - configuration.Values.TryGetValue("oidcClientSecret", out clientSecret); - - Dictionary providerConfig = new() - { - ["alias"] = alias, - ["providerId"] = "oidc", - ["enabled"] = true, - ["displayName"] = displayName ?? alias, - ["config"] = new Dictionary - { - ["discoveryUrl"] = discoveryUrl, - ["clientId"] = clientId, - ["clientSecret"] = clientSecret, - ["syncMode"] = "IMPORT" - } - }; - - await CreateIdentityProvider(keycloakDomain, adminToken, realmName, providerConfig, ct); - - actions.Add($"Configured OIDC identity provider '{alias}' in realm '{realmName}'"); - - if (discoveryUrl is not null) - { - actions.Add($"Discovery URL: {discoveryUrl}"); - } - - actions.Add("Client ID configured"); - actions.Add("Provider enabled as login option"); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"OIDC provider '{alias}' added to realm '{realmName}'", - Actions: actions); - } - - private async Task ConfigureAddSaml( - string keycloakDomain, string adminToken, string realmName, - ComponentConfiguration configuration, List actions, CancellationToken ct) - { - string alias = "saml-provider"; - - if (configuration.Values.TryGetValue("providerAlias", out string? aliasVal) - && !string.IsNullOrEmpty(aliasVal)) - { - alias = aliasVal; - } - - string? displayName = null; - configuration.Values.TryGetValue("providerDisplayName", out displayName); - - string? entityId = null; - configuration.Values.TryGetValue("samlEntityId", out entityId); - - string? ssoUrl = null; - configuration.Values.TryGetValue("samlSsoUrl", out ssoUrl); - - string? certificate = null; - configuration.Values.TryGetValue("samlCertificate", out certificate); - - Dictionary providerConfig = new() - { - ["alias"] = alias, - ["providerId"] = "saml", - ["enabled"] = true, - ["displayName"] = displayName ?? alias, - ["config"] = new Dictionary - { - ["entityId"] = entityId, - ["singleSignOnServiceUrl"] = ssoUrl, - ["signingCertificate"] = certificate, - ["postBindingAuthnRequest"] = "true", - ["postBindingResponse"] = "true", - ["wantAssertionsSigned"] = "true" - } - }; - - await CreateIdentityProvider(keycloakDomain, adminToken, realmName, providerConfig, ct); - - actions.Add($"Configured SAML identity provider '{alias}' in realm '{realmName}'"); - - if (entityId is not null) - { - actions.Add($"SAML entity ID: {entityId}"); - } - - actions.Add("SSO URL configured"); - actions.Add("Provider enabled as login option"); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"SAML provider '{alias}' added to realm '{realmName}'", - Actions: actions); - } - - // ─── Configure: Update Theme ────────────────────────────────────────────── - - private async Task ConfigureUpdateTheme( - Kubernetes client, KubernetesCluster cluster, ComponentConfiguration configuration, - List actions, CancellationToken ct) - { - string realmName = "master"; - - if (configuration.Values.TryGetValue("realmName", out string? realm) - && !string.IsNullOrEmpty(realm)) - { - realmName = realm; - } - - string keycloakDomain = GetKeycloakDomain(configuration); - string adminToken = await GetAdminToken(keycloakDomain, ct); - string themeName = $"{realmName}-theme"; - - await DeployRealmTheme(client, keycloakDomain, adminToken, realmName, themeName, configuration, actions, ct); - - return new InstallResult( - Success: true, - ComponentName: ComponentName, - Message: $"Theme updated for realm '{realmName}'", - Actions: actions); - } - - /// - /// Deploys a custom theme for a realm. Keycloak themes are deployed as - /// ConfigMaps mounted into the Keycloak pod, then activated via the Admin API. - /// - /// Theme components: - /// - login/theme.properties — colors, fonts - /// - login/resources/img/logo.png — logo image - /// - login/resources/img/bg.jpg — background image - /// - login/resources/css/custom.css — CSS overrides - /// - private async Task DeployRealmTheme( - Kubernetes client, string keycloakDomain, string adminToken, string realmName, - string themeName, ComponentConfiguration configuration, List actions, CancellationToken ct) - { - actions.Add($"Deployed custom theme '{themeName}'"); - - // Build the theme properties from configuration values. - - StringBuilder themeProperties = new(); - themeProperties.AppendLine($"parent=keycloak"); - themeProperties.AppendLine($"import=common/keycloak"); - - string? primaryColor = null; - string? secondaryColor = null; - string? logo = null; - string? background = null; - - if (configuration.Values.TryGetValue("themePrimaryColor", out primaryColor) - && !string.IsNullOrEmpty(primaryColor)) - { - themeProperties.AppendLine($"primaryColor={primaryColor}"); - } - - if (configuration.Values.TryGetValue("themeSecondaryColor", out secondaryColor) - && !string.IsNullOrEmpty(secondaryColor)) - { - themeProperties.AppendLine($"secondaryColor={secondaryColor}"); - } - - configuration.Values.TryGetValue("themeLogo", out logo); - configuration.Values.TryGetValue("themeBackground", out background); - - // Build CSS overrides based on the configuration. - - StringBuilder css = new(); - - if (!string.IsNullOrEmpty(primaryColor)) - { - css.AppendLine($":root {{ --pf-global--primary-color--100: {primaryColor}; }}"); - } - - if (!string.IsNullOrEmpty(background)) - { - css.AppendLine($".login-pf body {{ background-image: url('{background}'); background-size: cover; }}"); - } - - // Create a ConfigMap with the theme files. - - Dictionary themeData = new() - { - ["theme.properties"] = themeProperties.ToString() - }; - - if (css.Length > 0) - { - themeData["custom.css"] = css.ToString(); - } - - V1ConfigMap themeConfigMap = new() - { - Metadata = new V1ObjectMeta - { - Name = $"keycloak-theme-{realmName}", - NamespaceProperty = DefaultNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "keycloak-theme", - ["entkube.io/realm"] = realmName - } - }, - Data = themeData - }; - - try - { - await client.CoreV1.ReplaceNamespacedConfigMapAsync( - themeConfigMap, $"keycloak-theme-{realmName}", DefaultNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedConfigMapAsync(themeConfigMap, DefaultNamespace, cancellationToken: ct); - } - - // Build the actions summary. - - List themeDetails = new(); - - if (!string.IsNullOrEmpty(logo)) - { - themeDetails.Add("logo uploaded"); - } - - if (!string.IsNullOrEmpty(primaryColor)) - { - themeDetails.Add($"primary color {primaryColor}"); - } - - if (!string.IsNullOrEmpty(background)) - { - themeDetails.Add("background set"); - } - - if (themeDetails.Count > 0) - { - actions.Add($"Theme: {string.Join(", ", themeDetails)}"); - } - - // Activate the theme on the realm via Admin API. - - await SetRealmTheme(keycloakDomain, adminToken, realmName, themeName, ct); - actions.Add("Realm login page configured with custom branding"); - } - - // ─── Keycloak Admin REST API Operations ─────────────────────────────────── - - private async Task GetAdminToken(string keycloakDomain, CancellationToken ct) - { - // Obtain an admin token via the master realm token endpoint. - - using HttpClient httpClient = new(); - httpClient.BaseAddress = new Uri($"https://{keycloakDomain}"); - - FormUrlEncodedContent formData = new(new Dictionary - { - ["grant_type"] = "password", - ["client_id"] = "admin-cli", - ["username"] = "admin", - ["password"] = GetAdminPasswordFromSecret() - }); - - HttpResponseMessage response = await httpClient.PostAsync( - "/realms/master/protocol/openid-connect/token", formData, ct); - - if (response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - - using JsonDocument doc = JsonDocument.Parse(body); - - if (doc.RootElement.TryGetProperty("access_token", out JsonElement tokenEl)) - { - return tokenEl.GetString() ?? ""; - } - } - - return ""; - } - - private async Task CreateRealmViaApi( - string keycloakDomain, string adminToken, string realmName, string? displayName, CancellationToken ct) - { - using HttpClient httpClient = CreateKeycloakClient(keycloakDomain, adminToken); - - Dictionary realmPayload = new() - { - ["realm"] = realmName, - ["enabled"] = true, - ["displayName"] = displayName ?? realmName - }; - - string json = JsonSerializer.Serialize(realmPayload); - using StringContent content = new(json, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await httpClient.PostAsync("/admin/realms", content, ct); - - // 409 = already exists, acceptable. - - if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.Conflict) - { - string error = await response.Content.ReadAsStringAsync(ct); - logger.LogWarning("Failed to create realm '{Realm}': {Error}", realmName, error); - } - } - - private async Task CreateIdentityProvider( - string keycloakDomain, string adminToken, string realmName, - Dictionary providerConfig, CancellationToken ct) - { - using HttpClient httpClient = CreateKeycloakClient(keycloakDomain, adminToken); - - string json = JsonSerializer.Serialize(providerConfig); - using StringContent content = new(json, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await httpClient.PostAsync( - $"/admin/realms/{realmName}/identity-provider/instances", content, ct); - - if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.Conflict) - { - string error = await response.Content.ReadAsStringAsync(ct); - logger.LogWarning("Failed to create identity provider in realm '{Realm}': {Error}", realmName, error); - } - } - - private async Task SetRealmTheme( - string keycloakDomain, string adminToken, string realmName, string themeName, CancellationToken ct) - { - using HttpClient httpClient = CreateKeycloakClient(keycloakDomain, adminToken); - - Dictionary realmUpdate = new() - { - ["loginTheme"] = themeName - }; - - string json = JsonSerializer.Serialize(realmUpdate); - using StringContent content = new(json, Encoding.UTF8, "application/json"); - - await httpClient.PutAsync($"/admin/realms/{realmName}", content, ct); - } - - // ─── Kubernetes Operations ──────────────────────────────────────────────── - - private async Task CreateAdminSecret(Kubernetes client, string namespaceName, string password, CancellationToken ct) - { - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = "keycloak-admin-credentials", - NamespaceProperty = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "keycloak" - } - }, - Type = "Opaque", - Data = new Dictionary - { - ["username"] = Encoding.UTF8.GetBytes("admin"), - ["password"] = Encoding.UTF8.GetBytes(password) - } - }; - - try - { - await client.CoreV1.ReplaceNamespacedSecretAsync(secret, "keycloak-admin-credentials", namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(secret, namespaceName, cancellationToken: ct); - } - } - - private async Task CreateKeycloakCR( - Kubernetes client, string namespaceName, string keycloakDomain, int replicas, - string databaseType, string? tlsIssuer, string dbHost, string dbCredentialsSecret, CancellationToken ct) - { - // Build the Keycloak custom resource for the operator. - // The db host and credentials secret are now dynamic — they point to - // whichever CNPG cluster the user selected, or fall back to the - // default keycloak-db-rw if no CNPG cluster was specified. - - Dictionary keycloakCR = new() - { - ["apiVersion"] = "k8s.keycloak.org/v2alpha1", - ["kind"] = "Keycloak", - ["metadata"] = new Dictionary - { - ["name"] = "platform-keycloak", - ["namespace"] = namespaceName - }, - ["spec"] = new Dictionary - { - ["instances"] = replicas, - ["hostname"] = new Dictionary - { - ["hostname"] = keycloakDomain - }, - ["http"] = new Dictionary - { - ["httpEnabled"] = true - }, - ["db"] = new Dictionary - { - ["vendor"] = "postgres", - ["host"] = dbHost, - ["database"] = "keycloak", - ["usernameSecret"] = new Dictionary - { - ["name"] = dbCredentialsSecret, - ["key"] = "username" - }, - ["passwordSecret"] = new Dictionary - { - ["name"] = dbCredentialsSecret, - ["key"] = "password" - } - } - } - }; - - // Add TLS config if issuer is specified. - - if (tlsIssuer is not null) - { - Dictionary spec = (Dictionary)keycloakCR["spec"]; - spec["ingress"] = new Dictionary - { - ["enabled"] = true, - ["annotations"] = new Dictionary - { - ["cert-manager.io/cluster-issuer"] = tlsIssuer - } - }; - } - - await ApplyNamespacedCustomObject(client, "k8s.keycloak.org", "v2alpha1", - namespaceName, "keycloaks", "platform-keycloak", keycloakCR, ct); - } - - private async Task DeployBankIdPlugin(Kubernetes client, string namespaceName, string version, CancellationToken ct) - { - // The BankID plugin is deployed as a ConfigMap containing the plugin - // metadata and download URL. The Keycloak Operator supports custom - // providers via the spec.additionalOptions or volume mounts. - - V1ConfigMap pluginConfigMap = new() - { - Metadata = new V1ObjectMeta - { - Name = "keycloak-bankid-plugin", - NamespaceProperty = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "keycloak-bankid" - }, - Annotations = new Dictionary - { - ["entkube.io/plugin-version"] = version - } - }, - Data = new Dictionary - { - ["plugin.properties"] = $"name=bankid-provider\nversion={version}\ntype=identity-provider" - } - }; - - try - { - await client.CoreV1.ReplaceNamespacedConfigMapAsync( - pluginConfigMap, "keycloak-bankid-plugin", namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedConfigMapAsync(pluginConfigMap, namespaceName, cancellationToken: ct); - } - } - - private async Task StoreBankIdCertificate( - Kubernetes client, string namespaceName, string realmName, string certDataB64, string password, CancellationToken ct) - { - // Store the BankID P12 certificate as a Kubernetes Secret. - - V1Secret certSecret = new() - { - Metadata = new V1ObjectMeta - { - Name = $"bankid-cert-{realmName}", - NamespaceProperty = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "keycloak-bankid", - ["entkube.io/realm"] = realmName - } - }, - Type = "Opaque", - Data = new Dictionary - { - ["certificate.p12"] = Convert.FromBase64String(certDataB64), - ["password"] = Encoding.UTF8.GetBytes(password) - } - }; - - try - { - await client.CoreV1.ReplaceNamespacedSecretAsync( - certSecret, $"bankid-cert-{realmName}", namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(certSecret, namespaceName, cancellationToken: ct); - } - } - - private async Task CloudNativePGAvailable(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "clusters.postgresql.cnpg.io", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - // ─── Helpers ────────────────────────────────────────────────────────────── - - private static string GetKeycloakDomain(ComponentConfiguration configuration) - { - if (configuration.Values.TryGetValue("keycloakDomain", out string? domain) - && !string.IsNullOrEmpty(domain)) - { - return domain; - } - - return "auth.local"; - } - - private static string GetAdminPasswordFromSecret() - { - // In practice, this would read from the mounted Secret. - // For the installer context, it's passed during install. - - return "admin"; - } - - private static HttpClient CreateKeycloakClient(string keycloakDomain, string adminToken) - { - HttpClient client = new() - { - BaseAddress = new Uri($"https://{keycloakDomain}") - }; - - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - return client; - } - - private async Task EnsureNamespace(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "keycloak" - } - } - }; - - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private static async Task ApplyNamespacedCustomObject( - Kubernetes client, string group, string version, string ns, string plural, string name, - Dictionary 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 async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Kyverno/KyvernoCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Kyverno/KyvernoCheck.cs deleted file mode 100644 index 0a970c0..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Kyverno/KyvernoCheck.cs +++ /dev/null @@ -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; - -/// -/// 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). -/// -public class KyvernoCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "kyverno"; - public string DisplayName => "Kyverno Policy Engine"; - - public KyvernoCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task 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 details = new(); - List 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 runningPods, List 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 - { - ["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 Pods, List ComponentLabels, string? Namespace, string? Version)> FindKyvernoPods(Kubernetes client, CancellationToken ct) - { - List pods = new(); - List 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Kyverno/KyvernoInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Kyverno/KyvernoInstaller.cs deleted file mode 100644 index 1868fa4..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Kyverno/KyvernoInstaller.cs +++ /dev/null @@ -1,365 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Kyverno; - -/// -/// 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. -/// -public class KyvernoInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - 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 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 outputTask = process.StandardOutput.ReadToEndAsync(ct); - Task 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); - } - - /// - /// Reconfigures Kyverno with new values. Supports: - /// - "admissionReplicas": number of admission controller replicas - /// - "backgroundReplicas": number of background controller replicas - /// - "validationAction": global policy enforcement (Audit/Enforce) - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 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 outputTask = process.StandardOutput.ReadToEndAsync(ct); - Task 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// 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. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"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 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 outputTask = process.StandardOutput.ReadToEndAsync(ct); - Task 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; - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/LetsEncrypt/LetsEncryptCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/LetsEncrypt/LetsEncryptCheck.cs deleted file mode 100644 index 56b5aab..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/LetsEncrypt/LetsEncryptCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class LetsEncryptCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "letsencrypt"; - public string DisplayName => "Let's Encrypt (Public TLS)"; - - public LetsEncryptCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // List all ClusterIssuers and find those using ACME (Let's Encrypt). - - List 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 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 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); - } - - /// - /// 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. - /// - private async Task> ListAcmeClusterIssuers(Kubernetes client, CancellationToken ct) - { - List acmeIssuers = new(); - - try - { - JsonElement result = await client.CustomObjects.ListClusterCustomObjectAsync( - 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 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); - } - - /// - /// 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. - /// - private async Task> DiscoverGateways(Kubernetes client, CancellationToken ct) - { - List gateways = new(); - - try - { - JsonElement result = await client.CustomObjects.ListClusterCustomObjectAsync( - 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) ──── - - /// - /// 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. - /// - 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); - } - - /// - /// 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. - /// - 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"); - } - - /// - /// 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. - /// - public static List ParseDnsZones(JsonElement solverElement) - { - List 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; - } - - /// - /// 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. - /// - public static Dictionary BuildConfigValues(List issuers) - { - Dictionary configValues = new(); - - // Global summary values. - - List 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; - } -} - -/// -/// 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. -/// -public record Dns01Details( - string? Provider, - string? SecretName, - string? HostedZone, - string? Project, - string? ClientId = null, - string? SubscriptionId = null, - string? TenantId = null, - string? ResourceGroup = null); - -/// -/// 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. -/// -public record Http01Details( - string Mode, - string? IngressClass = null, - string? GatewayName = null, - string? GatewayNamespace = null); - -/// -/// Represents a detected ACME ClusterIssuer with its key properties, -/// including detailed DNS01 solver configuration, HTTP01 solver mode, -/// and domain selectors. -/// -public record DetectedIssuer( - string Name, - string? Email, - string? AcmeServer, - string? SolverType, - bool IsReady, - Dns01Details? Dns01Details, - List DnsZones, - string? Http01Mode = null, - string? Http01IngressClass = null, - string? Http01GatewayName = null, - string? Http01GatewayNamespace = null); - -/// -/// Represents a discovered Gateway API Gateway resource on the cluster. -/// Used to populate gateway selection options in the Let's Encrypt schema. -/// -public record GatewayInfo(string Name, string Namespace); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/LetsEncrypt/LetsEncryptInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/LetsEncrypt/LetsEncryptInstaller.cs deleted file mode 100644 index f5a2c46..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/LetsEncrypt/LetsEncryptInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class LetsEncryptInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - public string ComponentName => "letsencrypt"; - - public LetsEncryptInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List 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 { "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 { "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 { $"Error: {ex.Message}" }); - } - } - - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - List 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 { $"Error: {ex.Message}" }); - } - } - - /// - /// Uninstalls Let's Encrypt by deleting the staging and production - /// ClusterIssuer resources from the cluster. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List 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 { $"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 solvers = new(); - - if (httpSolverEnabled) - { - solvers.Add(BuildHttp01Solver(httpSolverMode, httpIngressClass, httpGatewayName, httpGatewayNamespace)); - } - - if (dnsSolverEnabled && !string.IsNullOrEmpty(dnsProvider)) - { - Dictionary 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 issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = issuerName, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "letsencrypt" - } - }, - ["spec"] = new Dictionary - { - ["acme"] = new Dictionary - { - ["server"] = acmeServer, - ["email"] = email, - ["privateKeySecretRef"] = new Dictionary - { - ["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. - } - } - - /// - /// 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. - /// - private static Dictionary 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 parentRef = new() - { - ["name"] = gatewayName ?? "internal" - }; - - if (!string.IsNullOrEmpty(gatewayNamespace)) - { - parentRef["namespace"] = gatewayNamespace; - } - - return new Dictionary - { - ["http01"] = new Dictionary - { - ["gatewayHTTPRoute"] = new Dictionary - { - ["parentRefs"] = new List { parentRef } - } - } - }; - } - - // Traditional Ingress mode — cert-manager creates an Ingress resource - // with the specified class for serving the ACME challenge response. - - return new Dictionary - { - ["http01"] = new Dictionary - { - ["ingress"] = new Dictionary - { - ["ingressClassName"] = ingressClass - } - } - }; - } - - private static Dictionary BuildDnsSolver( - string provider, string? secretName, string? hostedZone, string? project) - { - Dictionary dns01Config = provider.ToLowerInvariant() switch - { - "cloudflare" => new Dictionary - { - ["cloudflare"] = new Dictionary - { - ["apiTokenSecretRef"] = new Dictionary - { - ["name"] = secretName ?? "cloudflare-api-token", - ["key"] = "api-token" - } - } - }, - - "route53" => new Dictionary - { - ["route53"] = new Dictionary - { - ["region"] = "eu-north-1", - ["hostedZoneID"] = hostedZone ?? "" - } - }, - - "azuredns" => new Dictionary - { - ["azureDNS"] = new Dictionary - { - ["hostedZoneName"] = hostedZone ?? "", - ["environment"] = "AzurePublicCloud" - } - }, - - "clouddns" => new Dictionary - { - ["cloudDNS"] = new Dictionary - { - ["project"] = project ?? "", - ["serviceAccountSecretRef"] = new Dictionary - { - ["name"] = secretName ?? "clouddns-service-account", - ["key"] = "key.json" - } - } - }, - - _ => new Dictionary() - }; - - return new Dictionary - { - ["dns01"] = dns01Config - }; - } - - private async Task 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/MinIO/MinIOCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/MinIO/MinIOCheck.cs deleted file mode 100644 index e2575c2..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/MinIO/MinIOCheck.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class MinIOCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "minio"; - public string DisplayName => "MinIO Object Storage (S3)"; - - public MinIOCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: MinIO Operator pods running in minio-operator namespace - - List 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 - { - ["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> FindOperatorPods(Kubernetes client, CancellationToken ct) - { - List 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 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/MinIO/MinIOInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/MinIO/MinIOInstaller.cs deleted file mode 100644 index b0a5f3e..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/MinIO/MinIOInstaller.cs +++ /dev/null @@ -1,864 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.MinIO; - -/// -/// 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. -/// -public class MinIOInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 - { - ["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 - { - ["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); - } - } - - /// - /// 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. - /// - 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()) - { - webhook.NamespaceSelector ??= new k8s.Models.V1LabelSelector(); - webhook.NamespaceSelector.MatchExpressions ??= new List(); - - // 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)"); - } - } - - /// - /// 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. - /// - 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); - } - } - } - - /// - /// 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. - /// - private async Task CreateAutomountPolicyException(Kubernetes client, string targetNamespace, CancellationToken ct) - { - object policyException = new Dictionary - { - ["apiVersion"] = "kyverno.io/v2", - ["kind"] = "PolicyException", - ["metadata"] = new Dictionary - { - ["name"] = "entkube-minio-operator-automount", - ["namespace"] = targetNamespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - ["spec"] = new Dictionary - { - ["exceptions"] = new List - { - new Dictionary - { - ["policyName"] = "restrict-sa-token-automount", - ["ruleNames"] = new List { "*" } - } - }, - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["resources"] = new Dictionary - { - ["namespaces"] = new List { targetNamespace }, - ["kinds"] = new List { "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); - } - } - - /// - /// 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. - /// - 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 - { - ["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); - } - } - - /// - /// 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. - /// - 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)"); - } - } - - /// - /// 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. - /// - 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); - } - } - - /// - /// 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. - /// - 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 - { - ["app.kubernetes.io/managed-by"] = "Helm" - }, - annotations = new Dictionary - { - ["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); - } - } - - /// - /// 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. - /// - 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 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); - } - - /// - /// Reconfigures MinIO Operator. Supports: - /// - "operatorReplicas": number of operator replicas - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-minio-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List setFlags = new(); - - if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas)) - { - setFlags.Add($"--set 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls the MinIO Operator from the cluster by removing the Helm release. - /// Warning: existing MinIO Tenant resources will become unmanaged. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/MongoDBCommunity/MongoDBCommunityCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/MongoDBCommunity/MongoDBCommunityCheck.cs deleted file mode 100644 index 63b4a17..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/MongoDBCommunity/MongoDBCommunityCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class MongoDBCommunityCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "mongodb-community"; - public string DisplayName => "MongoDB Community Operator"; - - public MongoDBCommunityCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: 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 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 - { - ["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 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; - } - - /// - /// 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. - /// - 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> FindOperatorPods(Kubernetes client, CancellationToken ct) - { - List 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 MongoCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "mongodbcommunity.mongodbcommunity.mongodb.com", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private async Task 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/MongoDBCommunity/MongoDBCommunityInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/MongoDBCommunity/MongoDBCommunityInstaller.cs deleted file mode 100644 index 97397dc..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/MongoDBCommunity/MongoDBCommunityInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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). -/// -public class MongoDBCommunityInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - string watchNamespace = options.Parameters?.TryGetValue("watchNamespace", out string? watchNs) == true - && !string.IsNullOrEmpty(watchNs) ? watchNs : "*"; - List 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); - } - } - } - - /// - /// Reconfigures the MongoDB Community Operator. Supports: - /// - "operatorReplicas": number of operator replicas - /// - "watchNamespace": namespace the operator watches for CRs - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-mongodb-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List setFlags = new(); - - if (configuration.Values.TryGetValue("operatorReplicas", out string? replicas)) - { - setFlags.Add($"--set 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls the MongoDB Community Operator from the cluster. Warning: existing - /// MongoDBCommunity resources will become unmanaged. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - private async Task EnsureNamespaceWithLabels(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "mongodb-platform" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Monitoring/MonitoringCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Monitoring/MonitoringCheck.cs deleted file mode 100644 index e05e7de..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Monitoring/MonitoringCheck.cs +++ /dev/null @@ -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; - -/// -/// 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) -/// -public class MonitoringCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "monitoring"; - public string DisplayName => "Monitoring (kube-prometheus-stack)"; - - public MonitoringCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: Prometheus pods in monitoring namespace - - List 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 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 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 - { - ["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> FindPodsByLabel(Kubernetes client, string ns, string labelSelector, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Monitoring/MonitoringInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Monitoring/MonitoringInstaller.cs deleted file mode 100644 index 3c8609d..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Monitoring/MonitoringInstaller.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.Monitoring; - -/// -/// 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. -/// -public class MonitoringInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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); - } - } - } - - /// - /// 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) - /// - 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 - """; - } - - /// - /// Uninstalls the kube-prometheus-stack from the cluster, removing Prometheus, - /// Grafana, Alertmanager, and all monitoring infrastructure. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"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 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); - } - - /// - /// 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 - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-monitoring-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/NetworkPolicies/NetworkPoliciesCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/NetworkPolicies/NetworkPoliciesCheck.cs deleted file mode 100644 index ca95944..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/NetworkPolicies/NetworkPoliciesCheck.cs +++ /dev/null @@ -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; - -/// -/// 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) -/// -public class NetworkPoliciesCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "network-policies"; - public string DisplayName => "Network Policies (Default-Deny + Ingress Allow)"; - - public NetworkPoliciesCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Find all customer namespaces (labeled with entkube.io/customer) - - List 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 - { - ["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> GetTenantNamespaces(Kubernetes client, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/NetworkPolicies/NetworkPoliciesInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/NetworkPolicies/NetworkPoliciesInstaller.cs deleted file mode 100644 index 9cb0dba..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/NetworkPolicies/NetworkPoliciesInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class NetworkPoliciesInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - public string ComponentName => "network-policies"; - - public NetworkPoliciesInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Find all customer namespaces (labeled with entkube.io/customer) - - List 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 { "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); - } - } - - /// - /// 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. - /// - 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 - { - ["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 { "Ingress" }, - // No ingress rules = deny all ingress - Ingress = new List() - } - }; - - await CreateOrUpdateNetworkPolicy(client, ns, policy, ct); - } - - /// - /// 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. - /// - 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 - { - ["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 { "Ingress" }, - Ingress = new List - { - new() - { - FromProperty = new List - { - // Allow from internal-ingress namespace - new() - { - NamespaceSelector = new V1LabelSelector - { - MatchLabels = new Dictionary - { - ["kubernetes.io/metadata.name"] = "internal-ingress" - } - } - }, - // Allow from external-ingress namespace - new() - { - NamespaceSelector = new V1LabelSelector - { - MatchLabels = new Dictionary - { - ["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> GetTenantNamespaces(Kubernetes client, CancellationToken ct) - { - List 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); - } - - /// - /// 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. - /// - public async Task 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); - } - - /// - /// Uninstalls network policies by deleting the default-deny-ingress and - /// allow-from-ingress NetworkPolicy resources from all tenant namespaces. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List actions = new(); - - try - { - Kubernetes client = BuildClient(cluster); - - // Find all tenant namespaces and remove network policies from each. - - List 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 { $"Error: {ex.Message}" }); - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/RabbitMQ/RabbitMQCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/RabbitMQ/RabbitMQCheck.cs deleted file mode 100644 index a05af62..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/RabbitMQ/RabbitMQCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class RabbitMQCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "rabbitmq"; - public string DisplayName => "RabbitMQ (Message Broker)"; - - public RabbitMQCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: RabbitMQ operator pods in rabbitmq-system namespace. - - List 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 - { - ["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 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> FindOperatorPods(Kubernetes client, CancellationToken ct) - { - List 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 RabbitmqClusterCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "rabbitmqclusters.rabbitmq.com", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private async Task 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/RabbitMQ/RabbitMQInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/RabbitMQ/RabbitMQInstaller.cs deleted file mode 100644 index d1b0264..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/RabbitMQ/RabbitMQInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class RabbitMQInstaller : IComponentInstaller -{ - private readonly ILogger logger; - - private const string DefaultVersion = "2.20.1"; - private const string DefaultTopologyVersion = "1.19.1"; - private const string DefaultNamespace = "rabbitmq-system"; - - /// - /// The official cluster operator manifest from the rabbitmq/cluster-operator - /// GitHub releases. Contains namespace, CRDs, RBAC, and the operator Deployment. - /// - private const string ClusterOperatorManifestUrl = - "https://github.com/rabbitmq/cluster-operator/releases/download/v{0}/cluster-operator.yml"; - - /// - /// The official messaging topology operator manifest (with cert-manager support). - /// This is the recommended variant when cert-manager is installed on the cluster. - /// - 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 logger) - { - this.logger = logger; - } - - public async Task 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 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); - } - } - } - - /// - /// 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. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls the RabbitMQ Cluster Operator by deleting the manifest resources. - /// Warning: existing RabbitmqCluster resources will become unmanaged. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string topologyVersion = options.Parameters?.GetValueOrDefault("topologyOperatorVersion") ?? DefaultTopologyVersion; - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisCheck.cs deleted file mode 100644 index c3ae03e..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class RedisCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "redis"; - public string DisplayName => "Redis (Cache & Session Store)"; - - public RedisCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Check 1: 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 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 - { - ["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 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> FindOperatorPods(Kubernetes client, CancellationToken ct) - { - List 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; - } - - /// - /// 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). - /// - 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisEnterpriseCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisEnterpriseCheck.cs deleted file mode 100644 index b7d4dda..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisEnterpriseCheck.cs +++ /dev/null @@ -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; - -/// -/// 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 -/// -public class RedisEnterpriseCheck : IAdoptionCheck -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task 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 details = new(); - List 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 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 - { - ["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> 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 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; - } - - /// - /// Discovers RedisEnterpriseCluster CRs across all namespaces. - /// Returns whether the CRD exists and how many REC instances are running. - /// - 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); - } - - /// - /// Counts RedisEnterpriseDatabase (REDB) resources across all namespaces. - /// - private async Task 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisInstaller.cs deleted file mode 100644 index 7323d24..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Redis/RedisInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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) -/// -public class RedisInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - bool sentinelEnabled = options.Parameters?.TryGetValue("sentinelEnabled", out string? sentVal) == true - ? sentVal == "true" - : true; - List 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); - } - } - } - - /// - /// 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 - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-redis-cfg-{Guid.NewGuid()}.kubeconfig"); - - try - { - await File.WriteAllTextAsync(tempKubeConfig, cluster.KubeConfig, ct); - - List 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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// Uninstalls the Redis operator from the cluster. - /// Warning: existing Redis clusters managed by the operator will become unmanaged. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - private static string GetRedisValues(bool sentinelEnabled, Dictionary? 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 - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "redis-platform" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/SecurityPoliciesCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/SecurityPoliciesCheck.cs deleted file mode 100644 index bcceb35..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/SecurityPoliciesCheck.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies; - -/// -/// 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. -/// -public class SecurityPoliciesCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "security-policies"; - public string DisplayName => "Security Policies (Kyverno ClusterPolicies)"; - - /// - /// The policies that must exist for the cluster to meet the security baseline. - /// These mirror exactly what the Terraform security_policies module deploys. - /// - public static readonly List RequiredPolicies = new() - { - "restrict-image-registries", - "restrict-sa-token-automount", - "require-seccomp-runtime-default", - "require-readonly-root-filesystem", - "deny-loadbalancer-services" - }; - - public SecurityPoliciesCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - logger.LogWarning("[DIAG] SecurityPoliciesCheck.CheckAsync starting for cluster {ClusterId}", cluster.Id); - - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // List all ClusterPolicy resources from the Kyverno CRD. - - List 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 - { - ["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> ListClusterPolicies(Kubernetes client, CancellationToken ct) - { - List 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 KyvernoCrdExists(Kubernetes client, CancellationToken ct) - { - try - { - await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync( - "clusterpolicies.kyverno.io", cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - /// - /// 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. - /// - private async Task 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/SecurityPoliciesInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/SecurityPoliciesInstaller.cs deleted file mode 100644 index 7869c5b..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/SecurityPoliciesInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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). -/// -public class SecurityPoliciesInstaller : IComponentInstaller -{ - private readonly ILogger logger; - private readonly WorkloadRemediator remediator; - - public string ComponentName => "security-policies"; - - /// - /// 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. - /// - private const string DefaultValidationAction = "Audit"; - - /// - /// 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. - /// - internal static readonly List 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" - }; - - /// - /// 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. - /// - public static readonly List WritableRootFsApps = new() - { - "keycloak", - "minio", - "harbor", - "harbor-core", - "harbor-registry", - "harbor-jobservice", - "harbor-trivy", - "postgresql", - "rabbitmq", - "redis", - "elasticsearch", - "grafana", - "gitea" - }; - - public SecurityPoliciesInstaller(ILogger logger, WorkloadRemediator remediator) - { - this.logger = logger; - this.remediator = remediator; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List 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 - { - new Dictionary - { - ["name"] = "disable-sa-automount", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Pod" } } } - } - }, - ["exclude"] = BuildExcludeBlock(), - ["preconditions"] = new Dictionary - { - ["all"] = new List - { - new Dictionary - { - ["key"] = "{{ request.namespace }}", - ["operator"] = "AnyNotIn", - ["value"] = DefaultExcludedNamespaces - }, - new Dictionary - { - ["key"] = "{{ request.object.metadata.labels.\"entkube.io/customer\" || '' }}", - ["operator"] = "NotEquals", - ["value"] = "" - } - } - }, - ["mutate"] = new Dictionary - { - ["patchStrategicMerge"] = new Dictionary - { - ["spec"] = new Dictionary { ["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 - { - new Dictionary - { - ["name"] = "mutate-seccomp", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Pod" } } } - } - }, - ["exclude"] = BuildExcludeBlock(), - ["mutate"] = new Dictionary - { - ["patchStrategicMerge"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["securityContext"] = new Dictionary - { - ["+(seccompProfile)"] = new Dictionary - { - ["+(type)"] = "RuntimeDefault" - } - } - } - } - } - }, - new Dictionary - { - ["name"] = "validate-seccomp", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Pod" } } } - } - }, - ["exclude"] = BuildExcludeBlock(), - ["validate"] = new Dictionary - { - ["message"] = "Pod must set securityContext.seccompProfile.type to RuntimeDefault or Localhost.", - ["pattern"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["securityContext"] = new Dictionary - { - ["seccompProfile"] = new Dictionary { ["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 - { - new Dictionary - { - ["name"] = "validate-readonly-rootfs", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Pod" } } } - } - }, - ["exclude"] = BuildReadonlyRootfsExcludeBlock(), - ["validate"] = new Dictionary - { - ["message"] = "All containers must set securityContext.readOnlyRootFilesystem to true.", - ["foreach"] = new List - { - new Dictionary - { - ["list"] = "request.object.spec.[initContainers, containers][]", - ["deny"] = new Dictionary - { - ["conditions"] = new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["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 - { - new Dictionary - { - ["name"] = "deny-service-type-loadbalancer", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Service" } } } - } - }, - ["exclude"] = BuildExcludeBlock(), - ["validate"] = new Dictionary - { - ["message"] = "Service type LoadBalancer is not allowed in tenant namespaces.", - ["pattern"] = new Dictionary - { - ["spec"] = new Dictionary { ["=(type)"] = "!LoadBalancer" } - } - } - }, - new Dictionary - { - ["name"] = "deny-service-type-nodeport", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Service" } } } - } - }, - ["exclude"] = BuildExcludeBlock(), - ["validate"] = new Dictionary - { - ["message"] = "Service type NodePort is not allowed in tenant namespaces.", - ["pattern"] = new Dictionary - { - ["spec"] = new Dictionary { ["=(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 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 - { - new Dictionary - { - ["name"] = "validate-image-registry", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary { ["resources"] = new Dictionary { ["kinds"] = new List { "Pod" } } } - } - }, - ["exclude"] = BuildExcludeBlock(), - ["validate"] = new Dictionary - { - ["message"] = registryMessage, - ["foreach"] = new List - { - new Dictionary - { - ["list"] = "request.object.spec.[initContainers, containers, ephemeralContainers][]", - ["deny"] = new Dictionary - { - ["conditions"] = new Dictionary - { - ["all"] = allowedRegistries.Select(reg => (object)new Dictionary - { - ["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 rules) - { - return new Dictionary - { - ["apiVersion"] = "kyverno.io/v1", - ["kind"] = "ClusterPolicy", - ["metadata"] = new Dictionary - { - ["name"] = name, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/part-of"] = "security-policies", - ["app.kubernetes.io/managed-by"] = "entkube" - }, - ["annotations"] = new Dictionary - { - ["policies.kyverno.io/description"] = description - } - }, - ["spec"] = new Dictionary - { - ["validationFailureAction"] = validationAction, - ["background"] = true, - ["rules"] = rules - } - }; - } - - internal static Dictionary BuildExcludeBlock() - { - return new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["resources"] = new Dictionary { ["namespaces"] = DefaultExcludedNamespaces } - } - } - }; - } - - /// - /// 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). - /// - private static Dictionary BuildReadonlyRootfsExcludeBlock() - { - return new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["resources"] = new Dictionary { ["namespaces"] = DefaultExcludedNamespaces } - }, - new Dictionary - { - ["resources"] = new Dictionary - { - ["selector"] = new Dictionary - { - ["matchExpressions"] = new List - { - new Dictionary - { - ["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 policyDict - && policyDict["metadata"] is Dictionary 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); - } - } - - /// - /// 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. - /// - private async Task> 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(); - } - } - - 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); - } - - /// - /// 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 - /// - public async Task 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 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); - } - - /// - /// 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. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - List 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 { $"Error: {ex.Message}" }); - } - } - - /// - /// 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. - /// - private async Task RevertSecurityPatchesAsync(KubernetesCluster cluster, CancellationToken ct) - { - List 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); - } - } - - /// - /// 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. - /// - private async Task DeleteClusterPolicyIfExistsAsync( - Kubernetes client, string policyName, List 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); - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/WorkloadRemediator.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/WorkloadRemediator.cs deleted file mode 100644 index dfc50d9..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/SecurityPolicies/WorkloadRemediator.cs +++ /dev/null @@ -1,816 +0,0 @@ -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies; - -/// -/// 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. -/// -public class WorkloadRemediator -{ - private readonly ILogger logger; - - public WorkloadRemediator(ILogger logger) - { - this.logger = logger; - } - - /// - /// 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. - /// - public async Task RemediateAsync( - IKubernetes client, - List excludedNamespaces, - CancellationToken ct) - { - List patchedWorkloads = new(); - List 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 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); - } - - /// - /// Creates a Kyverno PolicyException for workloads that cannot be patched - /// and applies it to the cluster. - /// - public async Task ApplyPolicyExceptionsAsync( - IKubernetes client, - List 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); - } - } - } - - /// - /// 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. - /// - 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 exceptions = request.PolicyNames.Select(policy => (object)new Dictionary - { - ["policyName"] = policy, - ["ruleNames"] = new List { "*" } - }).ToList(); - - return new Dictionary - { - ["apiVersion"] = "kyverno.io/v2", - ["kind"] = "PolicyException", - ["metadata"] = new Dictionary - { - ["name"] = $"entkube-exception-{request.WorkloadName}", - ["namespace"] = request.Namespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube/exception-reason"] = "pre-policy-remediation-failure" - } - }, - ["spec"] = new Dictionary - { - ["exceptions"] = exceptions, - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["resources"] = new Dictionary - { - ["namespaces"] = new List { request.Namespace }, - ["names"] = new List { $"{request.WorkloadName}*" }, - ["kinds"] = new List { "Pod" } - } - } - } - } - } - }; - } - - // --- Private Implementation --- - - private async Task> GatherWorkloadsAsync( - IKubernetes client, - List excludedNamespaces, - CancellationToken ct) - { - List 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 patchedWorkloads, - List policyExceptions, - CancellationToken ct) - { - List 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); - } - - /// - /// 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. - /// - 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 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 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 containerPatches = workload.PodSpec.Containers - .Where(c => c.SecurityContext?.ReadOnlyRootFilesystem != true) - .Select(c => (object)new Dictionary - { - ["name"] = c.Name, - ["securityContext"] = new Dictionary - { - ["readOnlyRootFilesystem"] = true - } - }).ToList(); - - if (containerPatches.Count == 0) - { - return true; - } - - string patchJson = System.Text.Json.JsonSerializer.Serialize(new Dictionary - { - ["spec"] = new Dictionary - { - ["template"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["containers"] = containerPatches - } - } - } - }); - - return await ApplyPatchAsync(client, workload, patchJson, ct); - } - - /// - /// 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. - /// - public async Task RevertPatchesAsync( - IKubernetes client, - List excludedNamespaces, - CancellationToken ct) - { - List revertedWorkloads = new(); - List failedReverts = new(); - - // Gather all workloads across the cluster (already excludes platform namespaces). - - List 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); - } - - /// - /// Checks if any seccomp profile is set anywhere in the pod spec — - /// at pod level, on any container, or on any init container. - /// - 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; - } - - /// - /// Checks if readOnlyRootFilesystem is set to true on ANY container or - /// init container in the pod spec. - /// - 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; - } - - /// - /// 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. - /// - private async Task 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 - { - ["spec"] = new Dictionary - { - ["template"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["securityContext"] = new Dictionary - { - ["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 containerSeccompPatches = (workload.PodSpec.Containers ?? new List()) - .Where(c => c.SecurityContext?.SeccompProfile?.Type is not null) - .Select(c => (object)new Dictionary - { - ["name"] = c.Name, - ["securityContext"] = new Dictionary - { - ["seccompProfile"] = null - } - }).ToList(); - - List initContainerSeccompPatches = (workload.PodSpec.InitContainers ?? new List()) - .Where(c => c.SecurityContext?.SeccompProfile?.Type is not null) - .Select(c => (object)new Dictionary - { - ["name"] = c.Name, - ["securityContext"] = new Dictionary - { - ["seccompProfile"] = null - } - }).ToList(); - - if (containerSeccompPatches.Count > 0 || initContainerSeccompPatches.Count > 0) - { - Dictionary 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 - { - ["spec"] = new Dictionary - { - ["template"] = new Dictionary - { - ["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 containerPatches = (workload.PodSpec.Containers ?? new List()) - .Where(c => c.SecurityContext?.ReadOnlyRootFilesystem == true) - .Select(c => (object)new Dictionary - { - ["name"] = c.Name, - ["securityContext"] = new Dictionary - { - ["readOnlyRootFilesystem"] = false - } - }).ToList(); - - List initContainerPatches = (workload.PodSpec.InitContainers ?? new List()) - .Where(c => c.SecurityContext?.ReadOnlyRootFilesystem == true) - .Select(c => (object)new Dictionary - { - ["name"] = c.Name, - ["securityContext"] = new Dictionary - { - ["readOnlyRootFilesystem"] = false - } - }).ToList(); - - if (containerPatches.Count > 0 || initContainerPatches.Count > 0) - { - Dictionary 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 - { - ["spec"] = new Dictionary - { - ["template"] = new Dictionary - { - ["spec"] = specPatch - } - } - }); - - success = await ApplyPatchAsync(client, workload, patchJson, ct); - } - } - - return success; - } - - /// - /// Applies a JSON merge patch to a workload. Used for nulling out fields - /// (like seccompProfile) that strategic merge patch cannot remove. - /// - private async Task 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 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 --- - -/// -/// The result of scanning and remediating workloads before policy application. -/// -public record RemediationResult( - List PatchedWorkloads, - List PolicyExceptions); - -/// -/// A workload that was successfully patched to comply with a policy. -/// -public record PatchedWorkload( - string Name, - string Namespace, - string Kind, - string Reason); - -/// -/// A workload that needs a PolicyException because it couldn't be patched. -/// -public record PolicyExceptionEntry( - string WorkloadName, - string Namespace, - string WorkloadKind, - List PolicyNames); - -/// -/// Request to build a Kyverno PolicyException for a specific workload. -/// -public record PolicyExceptionRequest( - string WorkloadName, - string Namespace, - string WorkloadKind, - List PolicyNames); - -/// -/// Internal representation of a workload during scanning. -/// -internal record WorkloadInfo( - string Name, - string Namespace, - string Kind, - V1PodSpec PodSpec, - IDictionary? Labels = null); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Traefik/TraefikCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Traefik/TraefikCheck.cs deleted file mode 100644 index 624f174..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Traefik/TraefikCheck.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class TraefikCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "traefik"; - public string DisplayName => "Traefik Ingress Controller"; - - public TraefikCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Find running Traefik pods in the traefik or kube-system namespace. - - List 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 - { - ["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> FindTraefikPods(Kubernetes client, CancellationToken ct) - { - List 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 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 IngressClassExists(Kubernetes client, string name, CancellationToken ct) - { - try - { - await client.NetworkingV1.ReadIngressClassAsync(name, cancellationToken: ct); - return true; - } - catch - { - return false; - } - } - - private async Task 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/Traefik/TraefikInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/Traefik/TraefikInstaller.cs deleted file mode 100644 index 0d4aa07..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/Traefik/TraefikInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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) -/// -public class TraefikInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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); - } - } - } - - /// - /// Reconfigures Traefik with updated values. Since Traefik uses - /// `helm upgrade --install`, reconfiguration is the same operation. - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - ComponentInstallOptions options = new( - Namespace: configuration.Namespace, - Parameters: configuration.Values); - - return await InstallAsync(cluster, options, ct); - } - - /// - /// Uninstalls Traefik from the cluster. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - // ─── Public Static Helpers (testable without a Kubernetes client) ──── - - /// - /// Generates Helm values for Traefik deployment. When internalLoadBalancer is - /// enabled, applies provider-specific annotations so the Service gets a private LB. - /// - public static string GetTraefikValues(Dictionary? 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 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 - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private async Task RunCommand(string fileName, string arguments, CancellationToken ct) - { - System.Diagnostics.ProcessStartInfo psi = new(fileName, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException($"Failed to start {fileName}"); - } - - string output = await process.StandardOutput.ReadToEndAsync(ct); - string error = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{fileName} failed (exit {process.ExitCode}): {error}"); - } - - return output; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/TrustManager/TrustManagerCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/TrustManager/TrustManagerCheck.cs deleted file mode 100644 index 7b90188..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/TrustManager/TrustManagerCheck.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class TrustManagerCheck : IAdoptionCheck -{ - private readonly ILogger logger; - - public string ComponentName => "trust-manager"; - public string DisplayName => "trust-manager (CA Bundle Distribution)"; - - public TrustManagerCheck(ILogger logger) - { - this.logger = logger; - } - - public async Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - Kubernetes client = BuildClient(cluster); - List details = new(); - List missing = new(); - - // Trust-manager is typically installed in the same namespace as Kyverno - // or in cert-manager. Check both. - - (List 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 - { - ["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 Pods, string? Namespace)> FindTrustManagerPods(Kubernetes client, CancellationToken ct) - { - List 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 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); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/Components/TrustManager/TrustManagerInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/Components/TrustManager/TrustManagerInstaller.cs deleted file mode 100644 index 112fc77..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/Components/TrustManager/TrustManagerInstaller.cs +++ /dev/null @@ -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; - -/// -/// 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") -/// -public class TrustManagerInstaller : IComponentInstaller -{ - private readonly ILogger 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 logger) - { - this.logger = logger; - } - - public async Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string version = options.Version ?? DefaultVersion; - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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); - } - } - } - - /// - /// 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" - /// - public async Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - string targetNamespace = configuration.Namespace ?? DefaultNamespace; - List 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 { $"Error: {ex.Message}" }); - } - } - - /// - /// Uninstalls trust-manager from the cluster by removing the Helm release. - /// Warning: trust bundles will stop being distributed to namespaces. - /// - public async Task UninstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - string targetNamespace = options.Namespace ?? DefaultNamespace; - List actions = new(); - - string tempKubeConfig = Path.Combine(Path.GetTempPath(), $"entkube-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 { $"Error: {ex.Message}" }); - } - finally - { - if (File.Exists(tempKubeConfig)) - { - File.Delete(tempKubeConfig); - } - } - } - - /// - /// 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. - /// - 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 sources = new(); - - if (includeDefaultCAs) - { - sources.Add(new Dictionary - { - ["useDefaultCAs"] = true - }); - } - - Dictionary bundle = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary - { - ["name"] = bundleName - }, - ["spec"] = new Dictionary - { - ["sources"] = sources, - ["target"] = new Dictionary - { - ["configMap"] = new Dictionary - { - ["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 - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/component"] = "trust-bundle" - } - }, - Data = new Dictionary - { - ["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 source = new() - { - ["configMap"] = new Dictionary - { - ["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}"); - } - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/ConfigurationSchema.cs b/src/EntKube.Clusters/Features/AdoptCluster/ConfigurationSchema.cs deleted file mode 100644 index 9db78c5..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/ConfigurationSchema.cs +++ /dev/null @@ -1,193 +0,0 @@ -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// 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. -/// -public record ConfigurationSchema( - string ComponentName, - string DisplayName, - string Description, - List Parameters) -{ - /// - /// 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. - /// - public SchemaValidationResult Validate(Dictionary values) - { - Dictionary 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); - } - - /// - /// Groups parameters by their Group property for UI rendering. - /// Parameters within the same group are displayed together under - /// a shared heading. - /// - public Dictionary> GetParametersByGroup() - { - return Parameters - .GroupBy(p => p.Group) - .ToDictionary(g => g.Key, g => g.ToList()); - } -} - -/// -/// 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"). -/// -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? AllowedValues = null, - string? DependsOnKey = null, - List? DependsOnValues = null) -{ - /// - /// Validates a single value against this parameter's type and constraints. - /// Returns a result indicating whether the value is valid and, if not, why. - /// - 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(); - } -} - -/// -/// The data type of a configuration parameter. Determines how the UI renders -/// the input control and what validation rules apply. -/// -public enum ParameterType -{ - /// Free-form text input. - String, - - /// Numeric input with optional min/max constraints. - Integer, - - /// Toggle switch — true or false. - Boolean, - - /// Dropdown with predefined allowed values. - Select, - - /// Dropdown populated with the cluster's storage buckets. - StorageBucket, - - /// Dropdown populated with available CNPG PostgreSQL clusters. - PostgresCluster, - - /// Dropdown populated with available Redis clusters. - RedisCluster -} - -/// -/// Result of validating a single parameter value. -/// -public record ParameterValidationResult(bool IsValid, string? Error) -{ - public static ParameterValidationResult Valid() => new(true, null); - public static ParameterValidationResult Invalid(string error) => new(false, error); -} - -/// -/// Result of validating an entire configuration against a schema. -/// Contains per-key error messages for any invalid parameters. -/// -public record SchemaValidationResult(bool IsValid, Dictionary Errors); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/ConfigureComponent/ConfigureComponentEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/ConfigureComponent/ConfigureComponentEndpoint.cs deleted file mode 100644 index 1b341f6..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/ConfigureComponent/ConfigureComponentEndpoint.cs +++ /dev/null @@ -1,51 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; - -/// -/// 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. -/// -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 result = await handler.HandleAsync(id, request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} - -/// -/// The HTTP body for the configure endpoint. Component name comes from the URL path. -/// -public record ConfigureComponentBody( - string? Namespace = null, - Dictionary? Values = null) -{ - public Dictionary Values { get; init; } = Values ?? new(); -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/ConfigureComponent/ConfigureComponentHandler.cs b/src/EntKube.Clusters/Features/AdoptCluster/ConfigureComponent/ConfigureComponentHandler.cs deleted file mode 100644 index 27a3847..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/ConfigureComponent/ConfigureComponentHandler.cs +++ /dev/null @@ -1,126 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; - -/// -/// 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). -/// -public class ConfigureComponentHandler -{ - private readonly IClusterRepository repository; - private readonly IEnumerable installers; - - public ConfigureComponentHandler(IClusterRepository repository, IEnumerable installers) - { - this.repository = repository; - this.installers = installers; - } - - public async Task> 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($"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( - "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 available = installers.Select(i => i.ComponentName).ToList(); - return Result.Failure( - $"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()); - - if (!validation.IsValid) - { - string errors = string.Join("; ", validation.Errors.Select(e => e.Value)); - return Result.Failure($"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()); - - InstallResult result = await installer.ConfigureAsync(cluster, configuration, ct); - - if (!result.Success) - { - return Result.Failure(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); - } -} - -/// -/// 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" -/// -public record ConfigureComponentRequest( - string ComponentName, - string? Namespace = null, - Dictionary? Values = null) -{ - public Dictionary Values { get; init; } = Values ?? new(); -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/DeployComponent/DeployComponentEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/DeployComponent/DeployComponentEndpoint.cs deleted file mode 100644 index 68e6fb7..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/DeployComponent/DeployComponentEndpoint.cs +++ /dev/null @@ -1,36 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.AdoptCluster.DeployComponent; - -/// -/// 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. -/// -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 result = await handler.HandleAsync(id, request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/DeployComponent/DeployComponentHandler.cs b/src/EntKube.Clusters/Features/AdoptCluster/DeployComponent/DeployComponentHandler.cs deleted file mode 100644 index 82014e8..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/DeployComponent/DeployComponentHandler.cs +++ /dev/null @@ -1,198 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.DeployComponent; - -/// -/// 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. -/// -public class DeployComponentHandler -{ - private readonly IClusterRepository repository; - private readonly IEnumerable installers; - private readonly ILogger logger; - - public DeployComponentHandler(IClusterRepository repository, IEnumerable installers, ILogger logger) - { - this.repository = repository; - this.installers = installers; - this.logger = logger; - } - - public async Task> 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($"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( - "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 available = installers.Select(i => i.ComponentName).ToList(); - return Result.Failure( - $"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(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); - } - - /// - /// 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. - /// - 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()); - await securityPoliciesInstaller.InstallAsync(cluster, policyOptions, ct); - } - - /// - /// Patches all Kyverno MutatingWebhookConfigurations to add a namespaceSelector - /// that excludes namespaces labeled with policies.kyverno.io/ignore=true. - /// - 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()) - { - webhook.NamespaceSelector ??= new k8s.Models.V1LabelSelector(); - webhook.NamespaceSelector.MatchExpressions ??= new List(); - - 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? Parameters = null); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/GetComponentSchemas/GetComponentSchemasEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/GetComponentSchemas/GetComponentSchemasEndpoint.cs deleted file mode 100644 index 00b0232..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/GetComponentSchemas/GetComponentSchemasEndpoint.cs +++ /dev/null @@ -1,121 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.SharedKernel.Contracts; - -namespace EntKube.Clusters.Features.AdoptCluster.GetComponentSchemas; - -/// -/// 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. -/// -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 schemas = ComponentSchemas.GetAll(); - List dtos = MapSchemasToDtos(schemas.Values); - return Results.Ok(ApiResponse>.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 schemas = ComponentSchemas.GetAll(); - List dtos = MapSchemasToDtos(schemas.Values); - return Results.Ok(ApiResponse>.Ok(dtos)); - } - - // Enrich each schema using the cluster's detected component state. - - IReadOnlyDictionary allSchemas = ComponentSchemas.GetAll(); - - List 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>.Ok(enrichedDtos)); - }); - } - - private static List MapSchemasToDtos(IEnumerable 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 Parameters); - -public record ConfigurationParameterDto( - string Key, - string DisplayName, - string Description, - string Type, - string Group, - bool IsRequired, - string? DefaultValue, - int? Min, - int? Max, - List? AllowedValues, - string? DependsOnKey = null, - List? DependsOnValues = null); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/IAdoptionCheck.cs b/src/EntKube.Clusters/Features/AdoptCluster/IAdoptionCheck.cs deleted file mode 100644 index 3e76acd..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/IAdoptionCheck.cs +++ /dev/null @@ -1,69 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// 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. -/// -public interface IAdoptionCheck -{ - /// - /// 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. - /// - string ComponentName { get; } - - /// - /// Human-readable display name shown in the UI adoption report. - /// - string DisplayName { get; } - - /// - /// Connects to the cluster and inspects whether this component is present - /// and correctly configured. - /// - Task CheckAsync(KubernetesCluster cluster, CancellationToken ct = default); -} - -/// -/// 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. -/// -public record ComponentCheckResult( - string ComponentName, - ComponentStatus Status, - List Details, - List MissingItems, - DiscoveredConfiguration? Configuration = null); - -/// -/// 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. -/// -public record DiscoveredConfiguration( - string? Version, - string? Namespace, - string? HelmReleaseName, - Dictionary Values); - -/// -/// - Installed: component is present and fully functional -/// - Degraded: component is present but incomplete or unhealthy -/// - NotInstalled: component is entirely missing -/// -[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] -public enum ComponentStatus -{ - Installed, - Degraded, - NotInstalled -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/IComponentInstaller.cs b/src/EntKube.Clusters/Features/AdoptCluster/IComponentInstaller.cs deleted file mode 100644 index 755b929..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/IComponentInstaller.cs +++ /dev/null @@ -1,105 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.AdoptCluster; - -/// -/// 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. -/// -public interface IComponentInstaller -{ - /// - /// Must match the corresponding IAdoptionCheck.ComponentName so the coordinator - /// can pair checks with their installers. - /// - string ComponentName { get; } - - /// - /// Deploys the component onto the cluster. This is an idempotent operation — - /// calling it when the component already exists should be safe (upgrade/no-op). - /// - Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default); - - /// - /// 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. - /// - Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default); - - /// - /// 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. - /// - Task 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())); - } - - /// - /// 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. - /// - ConfigurationSchema GetSchema() => ComponentSchemas.GetSchema(ComponentName); - - /// - /// 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). - /// - Task> GetAvailableVersionsAsync(CancellationToken ct = default) - { - return Task.FromResult(new List()); - } -} - -/// -/// 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. -/// -public record ComponentInstallOptions( - string? Version = null, - string? Namespace = null, - Dictionary? Parameters = null); - -/// -/// 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. -/// -public record ComponentConfiguration( - string? Namespace, - Dictionary Values); - -/// -/// Reports what happened when deploying or configuring a component. -/// -public record InstallResult( - bool Success, - string ComponentName, - string Message, - List Actions); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/UninstallComponent/UninstallComponentEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/UninstallComponent/UninstallComponentEndpoint.cs deleted file mode 100644 index 29dabdd..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/UninstallComponent/UninstallComponentEndpoint.cs +++ /dev/null @@ -1,36 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.AdoptCluster.UninstallComponent; - -/// -/// 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. -/// -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 result = await handler.HandleAsync(id, request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/UninstallComponent/UninstallComponentHandler.cs b/src/EntKube.Clusters/Features/AdoptCluster/UninstallComponent/UninstallComponentHandler.cs deleted file mode 100644 index ec56005..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/UninstallComponent/UninstallComponentHandler.cs +++ /dev/null @@ -1,93 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.UninstallComponent; - -/// -/// Handles on-demand uninstallation of a single component from a cluster. -/// The caller specifies which component to remove (by name). This is the -/// reverse of DeployComponentHandler — it tears down a previously installed -/// component, cleaning up Helm releases, manifests, and optionally namespaces. -/// -/// The handler finds the matching IComponentInstaller and delegates to its -/// UninstallAsync method. On success, the component is marked as NotInstalled -/// in the persisted cluster state. -/// -public class UninstallComponentHandler -{ - private readonly IClusterRepository repository; - private readonly IEnumerable installers; - private readonly ILogger logger; - - public UninstallComponentHandler(IClusterRepository repository, IEnumerable installers, ILogger logger) - { - this.repository = repository; - this.installers = installers; - this.logger = logger; - } - - public async Task> HandleAsync(Guid clusterId, UninstallComponentRequest request, CancellationToken ct = default) - { - // Find the cluster we're operating on. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - // The cluster must be connected to uninstall anything from it. - - if (cluster.Status != ClusterStatus.Connected) - { - return Result.Failure( - "Cluster must be connected before uninstalling components."); - } - - // Find the installer for the requested component. The same installer - // that knows how to deploy a component also knows how to remove it. - - IComponentInstaller? installer = installers.FirstOrDefault( - i => i.ComponentName.Equals(request.ComponentName, StringComparison.OrdinalIgnoreCase)); - - if (installer is null) - { - List available = installers.Select(i => i.ComponentName).ToList(); - return Result.Failure( - $"No installer found for component '{request.ComponentName}'. Available: {string.Join(", ", available)}"); - } - - // Delegate to the installer's uninstall logic. For Helm-based components - // this typically runs "helm uninstall". For manifest-based components it - // deletes the applied Kubernetes resources. - - ComponentInstallOptions options = new( - Namespace: request.Namespace, - Parameters: request.Parameters); - - InstallResult result = await installer.UninstallAsync(cluster, options, ct); - - if (!result.Success) - { - return Result.Failure(result.Message); - } - - // Uninstall succeeded — mark the component as NotInstalled in persisted - // state so the UI reflects the updated status. - - cluster.MarkComponentUninstalled(request.ComponentName); - await repository.UpdateAsync(cluster, ct); - - logger.LogInformation("Component '{Component}' uninstalled from cluster '{Cluster}'", - request.ComponentName, cluster.Name); - - return Result.Success(result); - } -} - -public record UninstallComponentRequest( - string ComponentName, - string? Namespace = null, - Dictionary? Parameters = null); diff --git a/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/GetAvailableVersionsEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/GetAvailableVersionsEndpoint.cs deleted file mode 100644 index b5355d1..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/GetAvailableVersionsEndpoint.cs +++ /dev/null @@ -1,43 +0,0 @@ -using EntKube.SharedKernel.Contracts; - -namespace EntKube.Clusters.Features.AdoptCluster.UpgradeComponent; - -/// -/// Maps the available versions endpoint: -/// GET /api/components/{name}/versions -/// -/// Returns the list of available versions for a component, ordered newest-first. -/// The UI uses this to populate the version picker when the user clicks "Upgrade". -/// Not all components support versioned upgrades — those without an implementation -/// of GetAvailableVersionsAsync return an empty list. -/// -public static class GetAvailableVersionsEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/components/{name}/versions", async ( - string name, - IEnumerable installers, - CancellationToken ct) => - { - // Find the installer for this component. If none exists, we - // can't know what versions are available. - - IComponentInstaller? installer = installers.FirstOrDefault( - i => i.ComponentName.Equals(name, StringComparison.OrdinalIgnoreCase)); - - if (installer is null) - { - return Results.NotFound(ApiResponse>.Fail( - $"No installer found for component '{name}'.")); - } - - // Ask the installer what versions are available. Helm-based - // installers query the chart repo; others return an empty list. - - List versions = await installer.GetAvailableVersionsAsync(ct); - - return Results.Ok(ApiResponse>.Ok(versions)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/UpgradeComponentEndpoint.cs b/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/UpgradeComponentEndpoint.cs deleted file mode 100644 index cda33e5..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/UpgradeComponentEndpoint.cs +++ /dev/null @@ -1,36 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.AdoptCluster.UpgradeComponent; - -/// -/// Maps the component upgrade endpoint: -/// POST /api/clusters/{id}/components/upgrade -/// -/// Upgrades an already-installed component to a specific version. The request -/// body must include the component name and the target version. This is a -/// deliberate, version-specific operation — unlike Deploy which defaults to -/// the latest version when none is specified. -/// -public static class UpgradeComponentEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters/{id:guid}/components/upgrade", async ( - Guid id, - [FromBody] UpgradeComponentRequest request, - [FromServices] UpgradeComponentHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/UpgradeComponentHandler.cs b/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/UpgradeComponentHandler.cs deleted file mode 100644 index 74dc342..0000000 --- a/src/EntKube.Clusters/Features/AdoptCluster/UpgradeComponent/UpgradeComponentHandler.cs +++ /dev/null @@ -1,110 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.AdoptCluster.UpgradeComponent; - -/// -/// Handles upgrading an already-installed component to a specific version. -/// Unlike Deploy (which installs or re-installs), Upgrade explicitly requires -/// that the component is already Installed and that a target version is provided. -/// -/// Under the hood, the upgrade calls the same InstallAsync on the component -/// installer — because most installers use "helm upgrade --install" which is -/// idempotent. The difference is in the validation and the intent: the caller -/// explicitly wants to change the running version, not configure or install. -/// -public class UpgradeComponentHandler -{ - private readonly IClusterRepository repository; - private readonly IEnumerable installers; - private readonly ILogger logger; - - public UpgradeComponentHandler(IClusterRepository repository, IEnumerable installers, ILogger logger) - { - this.repository = repository; - this.installers = installers; - this.logger = logger; - } - - public async Task> HandleAsync(Guid clusterId, UpgradeComponentRequest request, CancellationToken ct = default) - { - // Find the cluster. An upgrade targets an existing cluster — if it - // doesn't exist, something is very wrong in the caller. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - // The cluster must be connected — we can't upgrade something on - // a cluster we can't reach. - - if (cluster.Status != ClusterStatus.Connected) - { - return Result.Failure( - "Cluster must be connected before upgrading components."); - } - - // Find the installer for the requested component. We need one that - // knows how to deploy this component type. - - IComponentInstaller? installer = installers.FirstOrDefault( - i => i.ComponentName.Equals(request.ComponentName, StringComparison.OrdinalIgnoreCase)); - - if (installer is null) - { - return Result.Failure( - $"No installer found for component '{request.ComponentName}'."); - } - - // The component must already be installed — you can't upgrade - // something that isn't there. Use Deploy/Install for that. - - ClusterComponent? existingComponent = cluster.Components.FirstOrDefault( - c => c.ComponentName.Equals(request.ComponentName, StringComparison.OrdinalIgnoreCase)); - - if (existingComponent is null || existingComponent.Status != ComponentStatus.Installed) - { - return Result.Failure( - $"Component '{request.ComponentName}' is not installed on this cluster. Use Deploy to install it first."); - } - - // Run the installer with the target version. Since installers use - // "helm upgrade --install", this applies the new chart version - // while preserving the existing configuration values. - - logger.LogInformation( - "Upgrading component '{ComponentName}' on cluster '{ClusterName}' from '{CurrentVersion}' to '{TargetVersion}'", - request.ComponentName, cluster.Name, existingComponent.Version ?? "unknown", request.Version); - - ComponentInstallOptions options = new( - Version: request.Version, - Namespace: existingComponent.Namespace); - - InstallResult result = await installer.InstallAsync(cluster, options, ct); - - if (!result.Success) - { - return Result.Failure(result.Message); - } - - // Upgrade succeeded — update the tracked version so the UI - // reflects what's actually running on the cluster. - - cluster.UpdateComponentVersion(request.ComponentName, request.Version); - await repository.UpdateAsync(cluster, ct); - - return Result.Success(result); - } -} - -/// -/// Request to upgrade a component to a specific version. Unlike Deploy, -/// the version is required — you must specify where you're going. -/// -public record UpgradeComponentRequest( - string ComponentName, - string Version); diff --git a/src/EntKube.Clusters/Features/Certificates/CertificateAuthorityHandler.cs b/src/EntKube.Clusters/Features/Certificates/CertificateAuthorityHandler.cs deleted file mode 100644 index 2c3d066..0000000 --- a/src/EntKube.Clusters/Features/Certificates/CertificateAuthorityHandler.cs +++ /dev/null @@ -1,1816 +0,0 @@ -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; -using EntKube.Clusters.Domain; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.Certificates; - -/// -/// Handles all Certificate Authority operations on a Kubernetes cluster. This is the -/// single entry point for listing, creating, deleting, and rotating CAs. It connects -/// to the cluster via kubeConfig and operates directly on cert-manager CRDs and K8s -/// Secrets. -/// -/// The handler combines what was previously split between InternalCACheck and -/// DomainCACheck into a unified view — any CA ClusterIssuer on the cluster is -/// returned regardless of type, with proper classification. -/// -public class CertificateAuthorityHandler -{ - private readonly IClusterRepository clusterRepository; - private readonly ILogger logger; - - private const string DefaultBundleName = "platform-trust-bundle"; - - public CertificateAuthorityHandler( - IClusterRepository clusterRepository, - ILogger logger) - { - this.clusterRepository = clusterRepository; - this.logger = logger; - } - - /// - /// Lists every Certificate Authority on the cluster. This discovers all - /// CA-type ClusterIssuers and classifies them as internal or domain CAs - /// based on their annotations. Also reads cert validity from the CA Secrets. - /// - public async Task> ListAsync(Guid clusterId, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return new List(); - } - - Kubernetes client = BuildClient(cluster); - List results = new(); - - // Collect all trust bundle sources so we can check membership - // and also discover CAs that only exist as trust entries. - - List trustBundleSources = await GetTrustBundleSources(client, ct); - HashSet trustBundleSecrets = trustBundleSources - .Where(s => s.SourceType == TrustSourceType.Secret) - .Select(s => s.Name) - .ToHashSet(); - - try - { - // List all ClusterIssuers and classify them. - - JsonElement issuers = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "cert-manager.io", - version: "v1", - plural: "clusterissuers", - cancellationToken: ct); - - if (!issuers.TryGetProperty("items", out JsonElement items)) - { - return results; - } - - foreach (JsonElement issuer in items.EnumerateArray()) - { - CertificateAuthorityInfo? ca = await ParseClusterIssuer(client, issuer, trustBundleSecrets, ct); - - if (ca is not null) - { - results.Add(ca); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list CAs for cluster {ClusterId}", clusterId); - } - - // Some CAs exist only in the trust bundle — they were imported - // for trust purposes without a corresponding ClusterIssuer (no - // private key on-cluster, so they can't sign new certs). These - // are typically external domain CAs whose root cert is distributed - // so workloads can validate connections. - - HashSet issuerSecrets = results - .Where(r => !string.IsNullOrEmpty(r.SecretName)) - .Select(r => r.SecretName) - .ToHashSet(); - - // Track thumbprints to avoid duplicates when the same cert appears - // in multiple trust bundle sources. - - HashSet seenThumbprints = results - .Where(r => r.Thumbprint is not null) - .Select(r => r.Thumbprint!) - .ToHashSet(); - - foreach (TrustBundleSourceInfo source in trustBundleSources) - { - // Skip Secret sources already covered by a ClusterIssuer. - - if (source.SourceType == TrustSourceType.Secret && issuerSecrets.Contains(source.Name)) - { - continue; - } - - List trustOnlyCAs = await ReadTrustOnlyCAs(client, source, ct); - - foreach (CertificateAuthorityInfo ca in trustOnlyCAs) - { - // Deduplicate by thumbprint — the same cert might be referenced - // from multiple sources or be identical to a ClusterIssuer's cert. - - if (ca.Thumbprint is not null && !seenThumbprints.Add(ca.Thumbprint)) - { - continue; - } - - results.Add(ca); - } - } - - // Some CA secrets exist in the cert-manager namespace without being - // referenced by a trust-manager Bundle or backing a ClusterIssuer. - // These are typically externally-managed CA chains imported via - // Terraform, external-secrets, or manual creation and distributed - // to workload namespaces through other mechanisms. - - // Build a combined set of secrets already covered — ClusterIssuer secrets - // plus trust bundle secrets in cert-manager namespace — so we don't - // double-discover certs we've already found. - - HashSet coveredSecrets = new(issuerSecrets); - - foreach (TrustBundleSourceInfo source in trustBundleSources) - { - if (source.SourceType == TrustSourceType.Secret - && string.Equals(source.Namespace, "cert-manager", StringComparison.OrdinalIgnoreCase)) - { - coveredSecrets.Add(source.Name); - } - } - - List uncoveredCAs = await DiscoverUncoveredCASecrets( - client, coveredSecrets, seenThumbprints, ct); - - results.AddRange(uncoveredCAs); - - return results; - } - - /// - /// Lists server/leaf certificates found across all namespaces on the cluster. - /// These are TLS certificates used by services — not CA certificates. The - /// method scans all namespaces for Secrets containing cert data, parses the - /// X.509 details, and deduplicates by thumbprint so each certificate appears - /// only once even if replicated across many namespaces. - /// - /// This is complementary to ListAsync (which shows CAs). Together they give - /// a full picture of certificates on the cluster. - /// - public async Task> ListCertificatesAsync(Guid clusterId, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return new List(); - } - - Kubernetes client = BuildClient(cluster); - List results = new(); - HashSet seenThumbprints = new(); - - // Load trust bundle sources so we can mark which certs are already - // published. This lets the UI show a publish button only for certs - // that aren't in the bundle yet. - - List trustBundleSources = await GetTrustBundleSources(client, ct); - HashSet trustBundleSecrets = trustBundleSources - .Where(s => s.SourceType == TrustSourceType.Secret) - .Select(s => s.Name) - .ToHashSet(); - - try - { - // Scan all namespaces for TLS and Opaque secrets containing cert - // data. Externally-managed certs (imported via Terraform, external- - // secrets, or manual creation) can be either type. - - V1SecretList secrets = await client.CoreV1.ListSecretForAllNamespacesAsync( - cancellationToken: ct); - - foreach (V1Secret secret in secrets.Items) - { - if (secret.Type != "kubernetes.io/tls" && secret.Type != "Opaque") - { - continue; - } - - if (secret.Data is null) - { - continue; - } - - byte[]? certData = null; - - if (!secret.Data.TryGetValue("tls.crt", out certData) - && !secret.Data.TryGetValue("ca.crt", out certData)) - { - continue; - } - - string secretName = secret.Metadata.Name; - string namespaceName = secret.Metadata.NamespaceProperty ?? "default"; - - // Parse all PEM certificates. We only take the first cert - // in the chain (the leaf) for the certificate list. CA certs - // in the chain belong in the CA list, not here. - - List certBlobs = ExtractPemCertificates(certData); - - if (certBlobs.Count == 0) - { - continue; - } - - try - { - byte[] leafBytes = certBlobs[0]; - X509Certificate2 cert = X509CertificateLoader.LoadCertificate(leafBytes); - - // Skip CA certificates — those belong in the CA list. - - X509BasicConstraintsExtension? basicConstraints = cert.Extensions - .OfType() - .FirstOrDefault(); - - if (basicConstraints is not null && basicConstraints.CertificateAuthority) - { - continue; - } - - CertDetails details = ExtractCertDetails(leafBytes); - - // Deduplicate by thumbprint — the same cert is often - // replicated across many namespaces. - - if (details.Thumbprint is not null && !seenThumbprints.Add(details.Thumbprint)) - { - continue; - } - - // Collect which namespaces this secret appears in. We'll - // track the first namespace and count the rest below. - - results.Add(new CertificateInfo( - Name: details.CommonName ?? secretName, - SecretName: secretName, - Namespace: namespaceName, - Domains: details.Domains, - NotBefore: details.NotBefore, - NotAfter: details.NotAfter, - Organization: details.Organization, - Subject: details.Subject, - IssuerDN: details.IssuerDN, - SerialNumber: details.SerialNumber, - Thumbprint: details.Thumbprint, - SecretType: secret.Type ?? "Opaque", - IsWildcard: details.CommonName?.StartsWith("*.") == true - || details.Domains.Any(d => d.StartsWith("*.")), - InTrustBundle: trustBundleSecrets.Contains(secretName))); - } - catch - { - // Malformed cert — skip. - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list certificates for cluster {ClusterId}", clusterId); - } - - return results; - } - - /// - /// Creates a new internal CA on the cluster. This provisions the full cert-manager - /// chain: self-signed bootstrap issuer → CA Certificate → CA ClusterIssuer, then - /// adds the root cert to the trust bundle. - /// - public async Task CreateInternalCAAsync( - Guid clusterId, CreateInternalCARequest request, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return CaOperationResult.Failure("Cluster not found."); - } - - Kubernetes client = BuildClient(cluster); - List actions = new(); - - try - { - // Step 1: Ensure the self-signed bootstrap issuer exists. - - await EnsureSelfSignedBootstrapIssuer(client, ct); - actions.Add("Ensured self-signed bootstrap ClusterIssuer exists"); - - // Step 2: Create the CA Certificate (signed by the bootstrap issuer). - - string secretName = $"{request.Name}-secret"; - string durationHours = $"{request.DurationDays * 24}h"; - - await CreateCACertificate(client, request.Name, secretName, - request.Organization, durationHours, ct); - actions.Add($"Created CA Certificate '{request.Name}'"); - - // Step 3: Create the CA ClusterIssuer that signs certs using the CA key pair. - - await CreateCAClusterIssuer(client, request.Name, secretName, ct); - actions.Add($"Created CA ClusterIssuer '{request.Name}'"); - - // Step 4: Add the CA cert to the platform trust bundle. - - string bundleName = request.BundleName ?? DefaultBundleName; - await AddCAToTrustBundle(client, bundleName, secretName, ct); - actions.Add($"Added CA to trust bundle '{bundleName}'"); - - logger.LogInformation("Created internal CA '{CA}' on cluster {Cluster}", request.Name, clusterId); - return CaOperationResult.Ok($"Internal CA '{request.Name}' created.", actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create internal CA on cluster {ClusterId}", clusterId); - return CaOperationResult.Failure($"Failed to create CA: {ex.Message}", actions); - } - } - - /// - /// Creates a domain-scoped CA. If tlsCert and tlsKey are provided, imports - /// an external CA. Otherwise creates a self-signed CA restricted to the - /// given domains via annotations. - /// - public async Task CreateDomainCAAsync( - Guid clusterId, CreateDomainCARequest request, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return CaOperationResult.Failure("Cluster not found."); - } - - Kubernetes client = BuildClient(cluster); - List actions = new(); - - try - { - string secretName = $"{request.Name}-secret"; - bool isExternal = !string.IsNullOrEmpty(request.TlsCert); - - if (isExternal) - { - // Import mode — create a TLS Secret from the provided cert+key. - - await CreateTlsSecret(client, secretName, request.TlsCert!, request.TlsKey, ct); - actions.Add($"Created TLS Secret '{secretName}' from imported certificate"); - - if (!string.IsNullOrEmpty(request.TlsKey)) - { - // Full import (cert + key) — create a CA ClusterIssuer. - - await CreateDomainCAClusterIssuer(client, request.Name, secretName, - request.Domains, isExternal: true, ct); - actions.Add($"Created domain CA ClusterIssuer '{request.Name}'"); - } - else - { - // Trust-only import — no ClusterIssuer, just add to trust bundle. - - actions.Add("Trust-only import (no private key) — skipping ClusterIssuer"); - } - } - else - { - // Self-signed domain CA — provision via cert-manager chain. - - await EnsureSelfSignedBootstrapIssuer(client, ct); - - string durationHours = $"{(request.DurationDays ?? 1825) * 24}h"; - string organization = request.Organization ?? "EntKube Domain CA"; - - await CreateCACertificate(client, request.Name, secretName, organization, durationHours, ct); - actions.Add($"Created CA Certificate '{request.Name}'"); - - await CreateDomainCAClusterIssuer(client, request.Name, secretName, - request.Domains, isExternal: false, ct); - actions.Add($"Created domain CA ClusterIssuer '{request.Name}'"); - } - - // Add to trust bundle. - - string bundleName = request.BundleName ?? DefaultBundleName; - await AddCAToTrustBundle(client, bundleName, secretName, ct); - actions.Add($"Added CA to trust bundle '{bundleName}'"); - - logger.LogInformation("Created domain CA '{CA}' for domains [{Domains}] on cluster {Cluster}", - request.Name, string.Join(", ", request.Domains), clusterId); - - return CaOperationResult.Ok($"Domain CA '{request.Name}' created.", actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to create domain CA on cluster {ClusterId}", clusterId); - return CaOperationResult.Failure($"Failed to create domain CA: {ex.Message}", actions); - } - } - - /// - /// Deletes a CA by removing its ClusterIssuer, Certificate, and Secret resources. - /// Also removes the CA from the trust bundle. - /// - public async Task DeleteCAAsync(Guid clusterId, string caName, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return CaOperationResult.Failure("Cluster not found."); - } - - Kubernetes client = BuildClient(cluster); - List actions = new(); - - try - { - // Find the CA's secret name before deleting. - - string? secretName = await GetIssuerSecretName(client, caName, ct); - - // Delete the ClusterIssuer. - - await DeleteClusterIssuerSafe(client, caName, ct); - actions.Add($"Deleted ClusterIssuer '{caName}'"); - - // Delete the Certificate resource (in cert-manager namespace). - - await DeleteCertificateSafe(client, caName, ct); - actions.Add($"Deleted Certificate '{caName}'"); - - // Delete the CA Secret. - - if (secretName is not null) - { - await DeleteSecretSafe(client, secretName, ct); - actions.Add($"Deleted Secret '{secretName}'"); - - // Remove from trust bundle. - - await RemoveCAFromTrustBundle(client, DefaultBundleName, secretName, ct); - actions.Add($"Removed CA from trust bundle"); - } - - logger.LogInformation("Deleted CA '{CA}' from cluster {Cluster}", caName, clusterId); - return CaOperationResult.Ok($"CA '{caName}' deleted.", actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to delete CA '{CA}' from cluster {ClusterId}", caName, clusterId); - return CaOperationResult.Failure($"Failed to delete CA: {ex.Message}", actions); - } - } - - /// - /// Rotates a CA by deleting and recreating its Certificate resource. cert-manager - /// automatically re-issues the CA certificate with a new key pair. All certificates - /// signed by the old CA will still be valid until they expire, and new certificates - /// will be signed by the new key. - /// - public async Task RotateCAAsync(Guid clusterId, string caName, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return CaOperationResult.Failure("Cluster not found."); - } - - Kubernetes client = BuildClient(cluster); - List actions = new(); - - try - { - // Read the existing Certificate resource to preserve its config. - - string? secretName = await GetIssuerSecretName(client, caName, ct); - - if (secretName is null) - { - return CaOperationResult.Failure($"CA '{caName}' not found or has no CA secret."); - } - - // Delete the existing CA Secret — cert-manager will detect the missing - // Secret and re-issue the Certificate, generating a new key pair. - - await DeleteSecretSafe(client, secretName, ct); - actions.Add($"Deleted CA Secret '{secretName}' to trigger rotation"); - actions.Add("cert-manager will re-issue the Certificate with a new key pair"); - - // Update the trust bundle to pick up the new certificate once re-issued. - - actions.Add("Trust bundle will be updated once the new certificate is ready"); - - logger.LogInformation("Rotated CA '{CA}' on cluster {Cluster}", caName, clusterId); - return CaOperationResult.Ok($"CA '{caName}' rotation initiated.", actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to rotate CA '{CA}' on cluster {ClusterId}", caName, clusterId); - return CaOperationResult.Failure($"Failed to rotate CA: {ex.Message}", actions); - } - } - - /// - /// Publishes a CA certificate to a trust-manager Bundle so workloads across - /// the cluster can trust it. This is for discovered CAs that exist as Secrets - /// in the cert-manager namespace but aren't referenced by any Bundle yet. - /// The user picks a target Bundle (or uses the default platform-trust-bundle), - /// and we add the Secret as a source. We also detect which key in the Secret - /// contains the certificate data (tls.crt or ca.crt). - /// - public async Task PublishToTrustBundleAsync( - Guid clusterId, string secretName, string? bundleName, CancellationToken ct) - { - KubernetesCluster? cluster = await clusterRepository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return CaOperationResult.Failure("Cluster not found."); - } - - Kubernetes client = BuildClient(cluster); - List actions = new(); - - try - { - // The secret may live in any namespace — find it by searching - // all namespaces. Externally-managed CA secrets (e.g., imported - // via Terraform or external-secrets) often live in workload - // namespaces rather than cert-manager. - - string certKey = "tls.crt"; - string? foundNamespace = null; - - try - { - V1SecretList allSecrets = await client.CoreV1.ListSecretForAllNamespacesAsync( - fieldSelector: $"metadata.name={secretName}", - cancellationToken: ct); - - // Find the first matching secret that has cert data. - - foreach (V1Secret candidate in allSecrets.Items) - { - if (candidate.Data is null) - { - continue; - } - - if (candidate.Data.ContainsKey("tls.crt")) - { - certKey = "tls.crt"; - foundNamespace = candidate.Metadata.NamespaceProperty; - break; - } - - if (candidate.Data.ContainsKey("ca.crt")) - { - certKey = "ca.crt"; - foundNamespace = candidate.Metadata.NamespaceProperty; - break; - } - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error searching for secret '{Secret}' across namespaces", secretName); - } - - if (foundNamespace is null) - { - return CaOperationResult.Failure($"Secret '{secretName}' not found in any namespace."); - } - - // Add the Secret as a source to the target Bundle. When the secret - // lives outside cert-manager, we include the namespace in the Bundle - // source spec so trust-manager can find it. - - string targetBundle = bundleName ?? DefaultBundleName; - await AddCAToTrustBundleWithKey(client, targetBundle, secretName, certKey, foundNamespace, ct); - actions.Add($"Added Secret '{foundNamespace}/{secretName}' (key: {certKey}) to Bundle '{targetBundle}'"); - - logger.LogInformation( - "Published CA secret '{Namespace}/{Secret}' to trust bundle '{Bundle}' on cluster {Cluster}", - foundNamespace, secretName, targetBundle, clusterId); - - return CaOperationResult.Ok( - $"Certificate from '{secretName}' published to trust bundle '{targetBundle}'.", actions); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to publish CA '{Secret}' to trust bundle on cluster {ClusterId}", - secretName, clusterId); - return CaOperationResult.Failure($"Failed to publish to trust bundle: {ex.Message}", actions); - } - } - - // ─── Private helpers ────────────────────────────────────────────────── - - /// - /// Parses a single ClusterIssuer JSON element into a CertificateAuthorityInfo, - /// classifying it as internal, domain, or LetsEncrypt based on its spec. - /// Returns null for issuers we don't track (e.g., pure self-signed bootstrap). - /// - private async Task ParseClusterIssuer( - Kubernetes client, JsonElement issuer, HashSet trustBundleSecrets, CancellationToken ct) - { - if (!issuer.TryGetProperty("metadata", out JsonElement metadata)) - { - return null; - } - - string? name = metadata.TryGetProperty("name", out JsonElement nameEl) ? nameEl.GetString() : null; - - if (name is null) - { - return null; - } - - // Skip the self-signed bootstrap issuer — it's infrastructure, not a real CA. - - if (name == "selfsigned-bootstrap") - { - return null; - } - - if (!issuer.TryGetProperty("spec", out JsonElement spec)) - { - return null; - } - - // Check for ACME (Let's Encrypt). - - if (spec.TryGetProperty("acme", out _)) - { - string acmeStatus = GetIssuerStatus(issuer); - - return new CertificateAuthorityInfo( - Name: name, - Type: CaType.LetsEncrypt, - SecretName: string.Empty, - Domains: new List(), - IsExternal: true, - InTrustBundle: true, - Status: acmeStatus, - NotBefore: null, - NotAfter: null, - Organization: "Let's Encrypt"); - } - - // Check for CA issuer. - - if (spec.TryGetProperty("ca", out JsonElement ca) && - ca.TryGetProperty("secretName", out JsonElement secretNameEl)) - { - string secretName = secretNameEl.GetString() ?? string.Empty; - - // Classify as domain CA if it has domain annotations. - - List domains = new(); - bool isExternal = false; - - if (metadata.TryGetProperty("annotations", out JsonElement annotations)) - { - if (annotations.TryGetProperty("entkube.io/domains", out JsonElement domainsEl)) - { - string? domainsStr = domainsEl.GetString(); - - if (!string.IsNullOrEmpty(domainsStr)) - { - domains = domainsStr.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); - } - } - - if (annotations.TryGetProperty("entkube.io/external-ca", out JsonElement extEl)) - { - isExternal = extEl.GetString() == "true"; - } - } - - CaType type = domains.Count > 0 ? CaType.Domain : CaType.Internal; - bool inTrustBundle = trustBundleSecrets.Contains(secretName); - string status = GetIssuerStatus(issuer); - - // Read cert details from the Secret. - - CertDetails? details = await ReadCertValidity(client, secretName, ct); - - return new CertificateAuthorityInfo( - Name: name, - Type: type, - SecretName: secretName, - Domains: domains, - IsExternal: isExternal, - InTrustBundle: inTrustBundle, - Status: status, - NotBefore: details?.NotBefore, - NotAfter: details?.NotAfter, - Organization: details?.Organization, - Subject: details?.Subject, - IssuerDN: details?.IssuerDN, - SerialNumber: details?.SerialNumber, - Thumbprint: details?.Thumbprint); - } - - return null; - } - - private static string GetIssuerStatus(JsonElement issuer) - { - if (issuer.TryGetProperty("status", out JsonElement status) && - status.TryGetProperty("conditions", out JsonElement conditions)) - { - foreach (JsonElement condition in conditions.EnumerateArray()) - { - if (condition.TryGetProperty("type", out JsonElement type) && - type.GetString() == "Ready") - { - string? condStatus = condition.TryGetProperty("status", out JsonElement s) - ? s.GetString() : null; - - return condStatus == "True" ? "Ready" : "NotReady"; - } - } - } - - return "Unknown"; - } - - /// - /// Reads the TLS certificate from a Secret and extracts validity dates, - /// organization, subject, issuer, serial number, and thumbprint. - /// - private async Task ReadCertValidity( - Kubernetes client, string secretName, CancellationToken ct) - { - try - { - V1Secret secret = await client.CoreV1.ReadNamespacedSecretAsync( - secretName, "cert-manager", cancellationToken: ct); - - if (secret.Data?.TryGetValue("tls.crt", out byte[]? certBytes) == true) - { - return ExtractCertDetails(certBytes); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not read cert from Secret '{Secret}'", secretName); - } - - return null; - } - - /// - /// Extracts all relevant details from raw certificate bytes. Used by both - /// the ClusterIssuer path and the trust-bundle-only path. - /// - private static CertDetails ExtractCertDetails(byte[] certBytes) - { - X509Certificate2 cert = X509CertificateLoader.LoadCertificate(certBytes); - - string? org = cert.Subject - .Split(',') - .Select(s => s.Trim()) - .FirstOrDefault(s => s.StartsWith("O="))? - .Substring(2); - - string? cn = cert.Subject - .Split(',') - .Select(s => s.Trim()) - .FirstOrDefault(s => s.StartsWith("CN="))? - .Substring(3); - - List domains = new(); - X509SubjectAlternativeNameExtension? sanExt = cert.Extensions - .OfType() - .FirstOrDefault(); - - if (sanExt is not null) - { - foreach (string dns in sanExt.EnumerateDnsNames()) - { - domains.Add(dns); - } - } - - return new CertDetails( - NotBefore: cert.NotBefore, - NotAfter: cert.NotAfter, - Organization: org, - CommonName: cn, - Subject: cert.Subject, - IssuerDN: cert.Issuer, - SerialNumber: cert.SerialNumber, - Thumbprint: cert.Thumbprint, - Domains: domains); - } - - /// - /// Scans the cert-manager namespace for Secrets containing CA certificates - /// that aren't already discovered through ClusterIssuers or trust-manager - /// Bundle sources. This catches externally-managed CA chains imported via - /// Terraform, external-secrets, or manual creation — certs that exist as - /// Kubernetes Secrets but aren't published through trust-manager Bundles. - /// - /// Only includes certificates where the X.509 Basic Constraints extension - /// marks them as CA certificates, avoiding noise from leaf/service certs. - /// - private async Task> DiscoverUncoveredCASecrets( - Kubernetes client, - HashSet alreadyCoveredSecrets, - HashSet seenThumbprints, - CancellationToken ct) - { - List results = new(); - - try - { - // List all secrets in the cert-manager namespace. This namespace is - // the conventional home for CA certificate material managed by or - // alongside cert-manager. - - V1SecretList secrets = await client.CoreV1.ListNamespacedSecretAsync( - "cert-manager", cancellationToken: ct); - - foreach (V1Secret secret in secrets.Items) - { - string secretName = secret.Metadata.Name; - - // Skip secrets already covered by a ClusterIssuer or trust bundle. - - if (alreadyCoveredSecrets.Contains(secretName)) - { - continue; - } - - if (secret.Data is null) - { - continue; - } - - // Try common certificate data keys. - - byte[]? certData = null; - - if (secret.Data.TryGetValue("tls.crt", out certData) - || secret.Data.TryGetValue("ca.crt", out certData)) - { - // Found cert data — parse it. - } - else - { - continue; - } - - // Parse all PEM certificates from the data (could be a chain). - - List certBlobs = ExtractPemCertificates(certData); - - foreach (byte[] certBytes in certBlobs) - { - try - { - // Only include certificates marked as CA in X.509 Basic Constraints. - // This filters out leaf/service certificates that happen to be - // stored in the cert-manager namespace. - - X509Certificate2 cert = X509CertificateLoader.LoadCertificate(certBytes); - X509BasicConstraintsExtension? basicConstraints = cert.Extensions - .OfType() - .FirstOrDefault(); - - if (basicConstraints is null || !basicConstraints.CertificateAuthority) - { - continue; - } - - CertDetails details = ExtractCertDetails(certBytes); - - // Deduplicate — skip if we've already seen this exact cert. - - if (details.Thumbprint is not null && !seenThumbprints.Add(details.Thumbprint)) - { - continue; - } - - string name = details.CommonName ?? secretName; - - results.Add(new CertificateAuthorityInfo( - Name: name, - Type: CaType.Domain, - SecretName: secretName, - Domains: details.Domains, - IsExternal: true, - InTrustBundle: false, - Status: "Discovered", - NotBefore: details.NotBefore, - NotAfter: details.NotAfter, - Organization: details.Organization, - Subject: details.Subject, - IssuerDN: details.IssuerDN, - SerialNumber: details.SerialNumber, - Thumbprint: details.Thumbprint)); - } - catch - { - // Malformed cert — skip. - } - } - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not scan cert-manager namespace for uncovered CA secrets"); - } - - return results; - } - - /// - /// Scans all trust-manager Bundle CRs and collects every source that - /// references a certificate — Secrets, ConfigMaps, and inline PEM. - /// This serves two purposes: checking whether a CA is trusted, and - /// discovering CAs that exist only in trust bundles. - /// - private async Task> GetTrustBundleSources(Kubernetes client, CancellationToken ct) - { - List sources = new(); - - try - { - JsonElement bundles = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "trust.cert-manager.io", - version: "v1alpha1", - plural: "bundles", - cancellationToken: ct); - - if (bundles.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement bundle in items.EnumerateArray()) - { - if (bundle.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("sources", out JsonElement bundleSources)) - { - foreach (JsonElement source in bundleSources.EnumerateArray()) - { - // Secret source — most common for cert-manager CAs. - // The namespace field tells us where the Secret lives; - // it defaults to the trust-manager namespace (cert-manager) - // but can reference Secrets in any namespace. - - if (source.TryGetProperty("secret", out JsonElement secretSource) && - secretSource.TryGetProperty("name", out JsonElement nameEl)) - { - string? name = nameEl.GetString(); - - string key = secretSource.TryGetProperty("key", out JsonElement keyEl) - ? keyEl.GetString() ?? "tls.crt" - : "tls.crt"; - - string ns = secretSource.TryGetProperty("namespace", out JsonElement nsEl) - ? nsEl.GetString() ?? "cert-manager" - : "cert-manager"; - - if (name is not null) - { - sources.Add(new TrustBundleSourceInfo( - SourceType: TrustSourceType.Secret, - Name: name, - Key: key, - InlineData: null, - Namespace: ns)); - } - } - - // ConfigMap source — often used for externally-imported CA certs. - - if (source.TryGetProperty("configMap", out JsonElement cmSource) && - cmSource.TryGetProperty("name", out JsonElement cmNameEl)) - { - string? name = cmNameEl.GetString(); - - string key = cmSource.TryGetProperty("key", out JsonElement cmKeyEl) - ? cmKeyEl.GetString() ?? "ca.crt" - : "ca.crt"; - - string ns = cmSource.TryGetProperty("namespace", out JsonElement cmNsEl) - ? cmNsEl.GetString() ?? "cert-manager" - : "cert-manager"; - - if (name is not null) - { - sources.Add(new TrustBundleSourceInfo( - SourceType: TrustSourceType.ConfigMap, - Name: name, - Key: key, - InlineData: null, - Namespace: ns)); - } - } - - // Inline PEM — raw certificate embedded directly in the Bundle spec. - - if (source.TryGetProperty("inLine", out JsonElement inlineEl)) - { - string? pem = inlineEl.GetString(); - - if (!string.IsNullOrEmpty(pem)) - { - sources.Add(new TrustBundleSourceInfo( - SourceType: TrustSourceType.InLine, - Name: "inline", - Key: string.Empty, - InlineData: pem)); - } - } - } - } - } - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not list trust bundles"); - } - - return sources; - } - - /// - /// Reads CAs from a trust bundle source that doesn't have a corresponding - /// ClusterIssuer. A single source may contain multiple PEM-encoded certificates - /// (e.g., a Secret or ConfigMap with concatenated PEM certs), so this returns - /// a list rather than a single item. - /// - private async Task> ReadTrustOnlyCAs( - Kubernetes client, TrustBundleSourceInfo source, CancellationToken ct) - { - List results = new(); - - try - { - byte[]? rawBytes = null; - - if (source.SourceType == TrustSourceType.Secret) - { - // Read certificate data from the Secret in its declared namespace. - - rawBytes = await ReadSecretCertData(client, source.Name, source.Key, source.Namespace, ct); - } - else if (source.SourceType == TrustSourceType.ConfigMap) - { - // Read certificate data from the ConfigMap in its declared namespace. - - rawBytes = await ReadConfigMapCertData(client, source.Name, source.Key, source.Namespace, ct); - } - else if (source.SourceType == TrustSourceType.InLine && source.InlineData is not null) - { - // Inline PEM is already available. - - rawBytes = Encoding.UTF8.GetBytes(source.InlineData); - } - - if (rawBytes is null || rawBytes.Length == 0) - { - return results; - } - - // Parse all PEM certificates from the data. A single source may - // contain multiple concatenated PEM blocks (one per CA cert). - - List certBlobs = ExtractPemCertificates(rawBytes); - - foreach (byte[] certBytes in certBlobs) - { - CertDetails details = ExtractCertDetails(certBytes); - - // Use the CN as the display name, falling back to the source name. - - string name = details.CommonName ?? source.Name; - - results.Add(new CertificateAuthorityInfo( - Name: name, - Type: CaType.Domain, - SecretName: source.Name, - Domains: details.Domains, - IsExternal: true, - InTrustBundle: true, - Status: "Trust-Only", - NotBefore: details.NotBefore, - NotAfter: details.NotAfter, - Organization: details.Organization, - Subject: details.Subject, - IssuerDN: details.IssuerDN, - SerialNumber: details.SerialNumber, - Thumbprint: details.Thumbprint)); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Could not read trust-only CA from source '{Source}'", source.Name); - } - - return results; - } - - /// - /// Reads certificate data from a Secret by trying the specified key - /// and common fallbacks (tls.crt, ca.crt). - /// - private static async Task ReadSecretCertData( - Kubernetes client, string secretName, string key, string namespaceName, CancellationToken ct) - { - try - { - V1Secret secret = await client.CoreV1.ReadNamespacedSecretAsync( - secretName, namespaceName, cancellationToken: ct); - - if (secret.Data is null) - { - return null; - } - - if (secret.Data.TryGetValue(key, out byte[]? data)) - { - return data; - } - - if (secret.Data.TryGetValue("tls.crt", out data)) - { - return data; - } - - if (secret.Data.TryGetValue("ca.crt", out data)) - { - return data; - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Secret doesn't exist — might be in a different namespace. - } - - return null; - } - - /// - /// Reads certificate data from a ConfigMap by trying the specified key - /// and common fallbacks (ca.crt, ca-certificates.crt). - /// - private static async Task ReadConfigMapCertData( - Kubernetes client, string configMapName, string key, string namespaceName, CancellationToken ct) - { - try - { - V1ConfigMap cm = await client.CoreV1.ReadNamespacedConfigMapAsync( - configMapName, namespaceName, cancellationToken: ct); - - if (cm.Data is null) - { - return null; - } - - string? pem = null; - - if (cm.Data.TryGetValue(key, out pem) || - cm.Data.TryGetValue("ca.crt", out pem) || - cm.Data.TryGetValue("ca-certificates.crt", out pem)) - { - return Encoding.UTF8.GetBytes(pem); - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // ConfigMap doesn't exist — might be in a different namespace. - } - - return null; - } - - /// - /// Splits raw PEM data into individual DER-encoded certificate blobs. - /// Handles both single certs and concatenated multi-cert PEM bundles. - /// - private static List ExtractPemCertificates(byte[] rawBytes) - { - List certs = new(); - string pem = Encoding.UTF8.GetString(rawBytes); - - // If the data doesn't look like PEM, try loading it as a single DER blob. - - if (!pem.Contains("-----BEGIN CERTIFICATE-----")) - { - try - { - X509CertificateLoader.LoadCertificate(rawBytes); - certs.Add(rawBytes); - } - catch - { - // Not a valid cert — skip. - } - - return certs; - } - - // Split concatenated PEM blocks and decode each one. - - int startIndex = 0; - const string beginMarker = "-----BEGIN CERTIFICATE-----"; - const string endMarker = "-----END CERTIFICATE-----"; - - while (true) - { - int begin = pem.IndexOf(beginMarker, startIndex, StringComparison.Ordinal); - - if (begin < 0) - { - break; - } - - int end = pem.IndexOf(endMarker, begin, StringComparison.Ordinal); - - if (end < 0) - { - break; - } - - int afterEnd = end + endMarker.Length; - string singlePem = pem[begin..afterEnd]; - string base64 = singlePem - .Replace(beginMarker, string.Empty) - .Replace(endMarker, string.Empty) - .Trim(); - - try - { - byte[] certBytes = Convert.FromBase64String(base64); - certs.Add(certBytes); - } - catch - { - // Malformed PEM block — skip it. - } - - startIndex = afterEnd; - } - - return certs; - } - - private async Task GetIssuerSecretName(Kubernetes client, string issuerName, CancellationToken ct) - { - try - { - JsonElement issuer = await client.CustomObjects.GetClusterCustomObjectAsync( - "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.LogDebug(ex, "Failed to read ClusterIssuer '{Issuer}'", issuerName); - } - - return null; - } - - // ─── K8s resource creation helpers ──────────────────────────────────── - - private static async Task EnsureSelfSignedBootstrapIssuer(Kubernetes client, CancellationToken ct) - { - Dictionary issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = "selfsigned-bootstrap" - }, - ["spec"] = new Dictionary - { - ["selfSigned"] = new Dictionary() - } - }; - - await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", - "selfsigned-bootstrap", issuer, ct); - } - - private static async Task CreateCACertificate( - Kubernetes client, string caName, string secretName, string organization, - string duration, CancellationToken ct) - { - Dictionary certificate = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "Certificate", - ["metadata"] = new Dictionary - { - ["name"] = caName, - ["namespace"] = "cert-manager" - }, - ["spec"] = new Dictionary - { - ["isCA"] = true, - ["duration"] = duration, - ["secretName"] = secretName, - ["commonName"] = $"{caName} Root CA", - ["subject"] = new Dictionary - { - ["organizations"] = new List { organization } - }, - ["privateKey"] = new Dictionary - { - ["algorithm"] = "ECDSA", - ["size"] = 256 - }, - ["issuerRef"] = new Dictionary - { - ["name"] = "selfsigned-bootstrap", - ["kind"] = "ClusterIssuer", - ["group"] = "cert-manager.io" - } - } - }; - - await ApplyNamespacedCustomObject(client, "cert-manager.io", "v1", "cert-manager", - "certificates", caName, certificate, ct); - } - - private static async Task CreateCAClusterIssuer( - Kubernetes client, string caName, string secretName, CancellationToken ct) - { - Dictionary issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = caName - }, - ["spec"] = new Dictionary - { - ["ca"] = new Dictionary - { - ["secretName"] = secretName - } - } - }; - - await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", - caName, issuer, ct); - } - - private static async Task CreateDomainCAClusterIssuer( - Kubernetes client, string caName, string secretName, - List domains, bool isExternal, CancellationToken ct) - { - Dictionary annotations = new() - { - ["entkube.io/domains"] = string.Join(",", domains) - }; - - if (isExternal) - { - annotations["entkube.io/external-ca"] = "true"; - } - - Dictionary issuer = new() - { - ["apiVersion"] = "cert-manager.io/v1", - ["kind"] = "ClusterIssuer", - ["metadata"] = new Dictionary - { - ["name"] = caName, - ["annotations"] = annotations - }, - ["spec"] = new Dictionary - { - ["ca"] = new Dictionary - { - ["secretName"] = secretName - } - } - }; - - await ApplyClusterCustomObject(client, "cert-manager.io", "v1", "clusterissuers", - caName, issuer, ct); - } - - private static async Task CreateTlsSecret( - Kubernetes client, string secretName, string tlsCert, string? tlsKey, CancellationToken ct) - { - Dictionary data = new() - { - ["tls.crt"] = Convert.FromBase64String(tlsCert) - }; - - if (!string.IsNullOrEmpty(tlsKey)) - { - data["tls.key"] = Convert.FromBase64String(tlsKey); - } - - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = "cert-manager" - }, - Type = "kubernetes.io/tls", - Data = data - }; - - try - { - await client.CoreV1.ReplaceNamespacedSecretAsync(secret, secretName, "cert-manager", cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - await client.CoreV1.CreateNamespacedSecretAsync(secret, "cert-manager", cancellationToken: ct); - } - } - - private async Task AddCAToTrustBundle( - Kubernetes client, string bundleName, string secretName, CancellationToken ct) - { - await AddCAToTrustBundleWithKey(client, bundleName, secretName, "tls.crt", "cert-manager", ct); - } - - /// - /// Adds a Secret as a source to a trust-manager Bundle. If the Bundle doesn't - /// exist, creates it with the Secret as the first source. The key parameter - /// specifies which data key in the Secret contains the certificate (e.g., - /// "tls.crt" or "ca.crt"). The namespace tells trust-manager where to find - /// the Secret — cert-manager is the default, but externally-managed CAs may - /// live in other namespaces. - /// - private async Task AddCAToTrustBundleWithKey( - Kubernetes client, string bundleName, string secretName, string certKey, - string namespaceName, CancellationToken ct) - { - try - { - JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync( - "trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct); - - List 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(source.GetRawText())!); - - if (source.TryGetProperty("secret", out JsonElement secretSource) && - secretSource.TryGetProperty("name", out JsonElement nameEl) && - nameEl.GetString() == secretName) - { - alreadyPresent = true; - } - } - } - - if (!alreadyPresent) - { - Dictionary secretSpec = new() - { - ["name"] = secretName, - ["key"] = certKey - }; - - // trust-manager defaults to cert-manager namespace; only - // include the namespace field when the secret lives elsewhere. - - if (!string.Equals(namespaceName, "cert-manager", StringComparison.OrdinalIgnoreCase)) - { - secretSpec["namespace"] = namespaceName; - } - - sources.Add(new Dictionary - { - ["secret"] = secretSpec - }); - - Dictionary patch = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary { ["name"] = bundleName }, - ["spec"] = new Dictionary { ["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 first source. - - Dictionary newSecretSpec = new() - { - ["name"] = secretName, - ["key"] = certKey - }; - - if (!string.Equals(namespaceName, "cert-manager", StringComparison.OrdinalIgnoreCase)) - { - newSecretSpec["namespace"] = namespaceName; - } - - Dictionary bundle = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary { ["name"] = bundleName }, - ["spec"] = new Dictionary - { - ["sources"] = new List - { - new Dictionary { ["useDefaultCAs"] = true }, - new Dictionary - { - ["secret"] = newSecretSpec - } - }, - ["target"] = new Dictionary - { - ["configMap"] = new Dictionary - { - ["key"] = "ca-certificates.crt" - } - } - } - }; - - await client.CustomObjects.CreateClusterCustomObjectAsync( - body: bundle, - group: "trust.cert-manager.io", - version: "v1alpha1", - plural: "bundles", - cancellationToken: ct); - } - } - - private async Task RemoveCAFromTrustBundle( - Kubernetes client, string bundleName, string secretName, CancellationToken ct) - { - try - { - JsonElement existing = await client.CustomObjects.GetClusterCustomObjectAsync( - "trust.cert-manager.io", "v1alpha1", "bundles", bundleName, ct); - - if (existing.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("sources", out JsonElement sourcesEl)) - { - List sources = new(); - - foreach (JsonElement source in sourcesEl.EnumerateArray()) - { - // Skip the source that references our secret. - - if (source.TryGetProperty("secret", out JsonElement secretSource) && - secretSource.TryGetProperty("name", out JsonElement nameEl) && - nameEl.GetString() == secretName) - { - continue; - } - - sources.Add(JsonSerializer.Deserialize(source.GetRawText())!); - } - - Dictionary patch = new() - { - ["apiVersion"] = "trust.cert-manager.io/v1alpha1", - ["kind"] = "Bundle", - ["metadata"] = new Dictionary { ["name"] = bundleName }, - ["spec"] = new Dictionary { ["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 (Exception ex) - { - logger.LogDebug(ex, "Could not remove CA from trust bundle '{Bundle}'", bundleName); - } - } - - // ─── Delete helpers ─────────────────────────────────────────────────── - - private static async Task DeleteClusterIssuerSafe(Kubernetes client, string name, CancellationToken ct) - { - try - { - await client.CustomObjects.DeleteClusterCustomObjectAsync( - "cert-manager.io", "v1", "clusterissuers", name, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Already gone — that's fine. - } - } - - private static async Task DeleteCertificateSafe(Kubernetes client, string name, CancellationToken ct) - { - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - "cert-manager.io", "v1", "cert-manager", "certificates", name, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Already gone. - } - } - - private static async Task DeleteSecretSafe(Kubernetes client, string name, CancellationToken ct) - { - try - { - await client.CoreV1.DeleteNamespacedSecretAsync(name, "cert-manager", cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Already gone. - } - } - - // ─── K8s apply helpers ──────────────────────────────────────────────── - - private static async Task ApplyClusterCustomObject( - Kubernetes client, string group, string version, string plural, - string name, Dictionary 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 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); - } -} - -// ─── Request models ────────────────────────────────────────────────────── - -public record CreateInternalCARequest( - string Name, - string Organization = "EntKube Platform", - int DurationDays = 3650, - string? BundleName = null); - -public record CreateDomainCARequest( - string Name, - List Domains, - string? TlsCert = null, - string? TlsKey = null, - string? Organization = null, - int? DurationDays = null, - string? BundleName = null); - -public record CaOperationResult(bool Success, string Message, List Actions) -{ - public static CaOperationResult Ok(string message, List? actions = null) => - new(true, message, actions ?? new()); - - public static CaOperationResult Failure(string message, List? actions = null) => - new(false, message, actions ?? new()); -} - -/// -/// Represents a server/leaf TLS certificate discovered on the cluster. -/// These are NOT CA certificates — they're service certs used by workloads, -/// ingresses, etc. Includes the first namespace where the Secret was found -/// and whether it's a wildcard. -/// -public record CertificateInfo( - string Name, - string SecretName, - string Namespace, - List Domains, - DateTimeOffset? NotBefore, - DateTimeOffset? NotAfter, - string? Organization, - string? Subject, - string? IssuerDN, - string? SerialNumber, - string? Thumbprint, - string SecretType, - bool IsWildcard, - bool InTrustBundle); - -/// -/// Represents a source referenced by a trust-manager Bundle, which can -/// be a Secret, ConfigMap, or inline PEM data. Includes the namespace -/// where the source lives — trust-manager Bundle sources can reference -/// Secrets and ConfigMaps in any namespace, not just cert-manager. -/// -internal record TrustBundleSourceInfo( - TrustSourceType SourceType, - string Name, - string Key, - string? InlineData, - string Namespace = "cert-manager"); - -/// -/// The type of trust bundle source — determines how to read the cert data. -/// -internal enum TrustSourceType { Secret, ConfigMap, InLine } - -/// -/// Holds all extracted X.509 certificate details, used internally to pass -/// cert information between parsing and model construction. -/// -internal record CertDetails( - DateTimeOffset NotBefore, - DateTimeOffset NotAfter, - string? Organization, - string? CommonName, - string Subject, - string IssuerDN, - string SerialNumber, - string Thumbprint, - List Domains); diff --git a/src/EntKube.Clusters/Features/Certificates/CertificateAuthorityInfo.cs b/src/EntKube.Clusters/Features/Certificates/CertificateAuthorityInfo.cs deleted file mode 100644 index f55d9c2..0000000 --- a/src/EntKube.Clusters/Features/Certificates/CertificateAuthorityInfo.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace EntKube.Clusters.Features.Certificates; - -/// -/// Represents a Certificate Authority discovered or managed on a Kubernetes cluster. -/// This is the read model returned by the API — it describes the current state of -/// a CA ClusterIssuer, including its type, trust status, validity, and domain scope. -/// Includes X.509 certificate details (subject, issuer, serial, thumbprint) so -/// operators can distinguish between CAs even when names are similar. -/// -public record CertificateAuthorityInfo( - string Name, - CaType Type, - string SecretName, - List Domains, - bool IsExternal, - bool InTrustBundle, - string Status, - DateTimeOffset? NotBefore, - DateTimeOffset? NotAfter, - string? Organization, - string? Subject = null, - string? IssuerDN = null, - string? SerialNumber = null, - string? Thumbprint = null); - -/// -/// Classifies the type of CA based on its purpose and configuration. -/// - Internal: General-purpose CA for platform services (mTLS, webhooks, etc.) -/// - Domain: Scoped to specific DNS domains (e.g., *.internal.corp.com) -/// - LetsEncrypt: ACME issuer for publicly trusted certificates -/// -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum CaType -{ - Internal, - Domain, - LetsEncrypt -} diff --git a/src/EntKube.Clusters/Features/Certificates/CertificateEndpoints.cs b/src/EntKube.Clusters/Features/Certificates/CertificateEndpoints.cs deleted file mode 100644 index 5c3f86e..0000000 --- a/src/EntKube.Clusters/Features/Certificates/CertificateEndpoints.cs +++ /dev/null @@ -1,189 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.Certificates; - -/// -/// Maps REST endpoints for Certificate Authority management on a cluster. -/// These endpoints allow listing, creating, deleting, and rotating CAs — -/// both internal (general-purpose) and domain-scoped. -/// -/// Routes: -/// GET /api/clusters/{id}/certificates/cas — list all CAs -/// POST /api/clusters/{id}/certificates/cas/internal — create internal CA -/// POST /api/clusters/{id}/certificates/cas/domain — create domain CA -/// DELETE /api/clusters/{id}/certificates/cas/{name} — delete a CA -/// POST /api/clusters/{id}/certificates/cas/{name}/rotate — rotate a CA -/// -public static class CertificateEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - // GET /api/clusters/{id}/certificates/cas — lists every CA on the cluster, - // regardless of type (internal, domain, ACME). Connects to the K8s API to - // discover ClusterIssuers and reads cert metadata from Secrets. - - app.MapGet("/api/clusters/{id:guid}/certificates/cas", async ( - Guid id, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - List cas = await handler.ListAsync(id, ct); - return Results.Ok(ApiResponse>.Ok(cas)); - }); - - // POST /api/clusters/{id}/certificates/cas/internal — creates a new internal CA. - // This provisions the full cert-manager chain (bootstrap issuer → Certificate - // → CA ClusterIssuer) and adds the root cert to the trust bundle. - - app.MapPost("/api/clusters/{id:guid}/certificates/cas/internal", async ( - Guid id, - [FromBody] CreateInternalCARequest request, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - CaOperationResult result = await handler.CreateInternalCAAsync(id, request, ct); - - if (!result.Success) - { - return Results.BadRequest(ApiResponse.Fail(result.Message)); - } - - return Results.Ok(ApiResponse.Ok(result)); - }); - - // POST /api/clusters/{id}/certificates/cas/domain — creates a domain CA. - // Supports three modes: self-signed, imported external (cert+key), or - // trust-only (cert only, no signing capability). - - app.MapPost("/api/clusters/{id:guid}/certificates/cas/domain", async ( - Guid id, - [FromBody] CreateDomainCARequest request, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - if (request.Domains.Count == 0) - { - return Results.BadRequest(ApiResponse.Fail("At least one domain is required.")); - } - - CaOperationResult result = await handler.CreateDomainCAAsync(id, request, ct); - - if (!result.Success) - { - return Results.BadRequest(ApiResponse.Fail(result.Message)); - } - - return Results.Ok(ApiResponse.Ok(result)); - }); - - // DELETE /api/clusters/{id}/certificates/cas/{name} — removes a CA and - // all its resources (ClusterIssuer, Certificate, Secret, trust bundle entry). - - app.MapDelete("/api/clusters/{id:guid}/certificates/cas/{name}", async ( - Guid id, - string name, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - CaOperationResult result = await handler.DeleteCAAsync(id, name, ct); - - if (!result.Success) - { - return Results.BadRequest(ApiResponse.Fail(result.Message)); - } - - return Results.Ok(ApiResponse.Ok(result)); - }); - - // POST /api/clusters/{id}/certificates/cas/{name}/rotate — triggers - // CA key rotation. Deletes the CA Secret so cert-manager re-issues the - // Certificate with a new key pair. - - app.MapPost("/api/clusters/{id:guid}/certificates/cas/{name}/rotate", async ( - Guid id, - string name, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - CaOperationResult result = await handler.RotateCAAsync(id, name, ct); - - if (!result.Success) - { - return Results.BadRequest(ApiResponse.Fail(result.Message)); - } - - return Results.Ok(ApiResponse.Ok(result)); - }); - - // POST /api/clusters/{id}/certificates/cas/{name}/publish — publishes - // a discovered CA Secret to a trust-manager Bundle so workloads across - // the cluster can trust it. Accepts an optional bundleName in the body; - // defaults to "platform-trust-bundle". - - app.MapPost("/api/clusters/{id:guid}/certificates/cas/{name}/publish", async ( - Guid id, - string name, - [FromBody] PublishToBundleRequest? request, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - CaOperationResult result = await handler.PublishToTrustBundleAsync( - id, name, request?.BundleName, ct); - - if (!result.Success) - { - return Results.BadRequest(ApiResponse.Fail(result.Message)); - } - - return Results.Ok(ApiResponse.Ok(result)); - }); - - // GET /api/clusters/{id}/certificates/list — lists all server/leaf TLS - // certificates across all namespaces. These are service certs (not CAs), - // deduplicated by thumbprint. - - app.MapGet("/api/clusters/{id:guid}/certificates/list", async ( - Guid id, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - List certs = await handler.ListCertificatesAsync(id, ct); - return Results.Ok(ApiResponse>.Ok(certs)); - }); - - // POST /api/clusters/{id}/certificates/publish — publishes a leaf/server - // certificate to a trust-manager Bundle. The request body identifies the - // secret by name and namespace, since leaf cert names may collide across - // namespaces. Reuses the same trust bundle publish mechanism as CAs. - - app.MapPost("/api/clusters/{id:guid}/certificates/publish", async ( - Guid id, - [FromBody] PublishCertToBundleRequest request, - [FromServices] CertificateAuthorityHandler handler, - CancellationToken ct) => - { - CaOperationResult result = await handler.PublishToTrustBundleAsync( - id, request.SecretName, request.BundleName, ct); - - if (!result.Success) - { - return Results.BadRequest(ApiResponse.Fail(result.Message)); - } - - return Results.Ok(ApiResponse.Ok(result)); - }); - } -} - -/// -/// Optional request body for the publish-to-trust-bundle endpoint. -/// If omitted or BundleName is null, the default platform-trust-bundle is used. -/// -public record PublishToBundleRequest(string? BundleName = null); - -/// -/// Request body for publishing a leaf/server certificate to a trust bundle. -/// Requires SecretName and Namespace since leaf cert names may collide. -/// -public record PublishCertToBundleRequest(string SecretName, string Namespace, string? BundleName = null); diff --git a/src/EntKube.Clusters/Features/CheckConnectivity/CheckConnectivityEndpoint.cs b/src/EntKube.Clusters/Features/CheckConnectivity/CheckConnectivityEndpoint.cs deleted file mode 100644 index c3e26de..0000000 --- a/src/EntKube.Clusters/Features/CheckConnectivity/CheckConnectivityEndpoint.cs +++ /dev/null @@ -1,29 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.CheckConnectivity; - -/// -/// Maps POST /api/clusters/{id}/check-connectivity — probes the cluster's API -/// server and updates its status to Connected or Unreachable. -/// -public static class CheckConnectivityEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters/{id:guid}/check-connectivity", async ( - Guid id, - CheckConnectivityHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/CheckConnectivity/CheckConnectivityHandler.cs b/src/EntKube.Clusters/Features/CheckConnectivity/CheckConnectivityHandler.cs deleted file mode 100644 index d7c8241..0000000 --- a/src/EntKube.Clusters/Features/CheckConnectivity/CheckConnectivityHandler.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.CheckConnectivity; - -/// -/// Probes a cluster's API server to verify it's reachable, then updates the -/// cluster status accordingly. This is the mechanism that moves a cluster from -/// "Pending" to "Connected" (or marks it "Unreachable" if the probe fails). -/// -/// The probe calls the Kubernetes /version endpoint — it's lightweight, always -/// available, and doesn't require any RBAC beyond basic authentication. -/// -public class CheckConnectivityHandler -{ - private readonly IClusterRepository repository; - private readonly ILogger logger; - - public CheckConnectivityHandler(IClusterRepository repository, ILogger logger) - { - this.repository = repository; - this.logger = logger; - } - - public async Task> HandleAsync(Guid clusterId, CancellationToken ct = default) - { - // Find the cluster. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - // Attempt to connect to the cluster's API server using its kubeconfig. - - try - { - using MemoryStream stream = new(Encoding.UTF8.GetBytes(cluster.KubeConfig)); - - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - stream, currentContext: cluster.ContextName); - - using Kubernetes client = new(config); - - // Call /version — lightweight probe that confirms API server reachability. - - k8s.Models.VersionInfo version = await client.Version.GetCodeAsync(ct); - - // If we got here, the cluster is reachable. - - cluster.MarkConnected(); - await repository.UpdateAsync(cluster, ct); - - logger.LogInformation( - "Cluster '{Name}' is connected. Server version: {Version}", - cluster.Name, $"{version.Major}.{version.Minor}"); - - return Result.Success(new ConnectivityResult( - Status: "Connected", - ServerVersion: $"{version.Major}.{version.Minor}", - Message: "Cluster API server is reachable.")); - } - catch (Exception ex) - { - // The probe failed — mark unreachable. - - cluster.MarkUnreachable(); - await repository.UpdateAsync(cluster, ct); - - logger.LogWarning(ex, "Cluster '{Name}' is unreachable.", cluster.Name); - - return Result.Success(new ConnectivityResult( - Status: "Unreachable", - ServerVersion: null, - Message: $"Failed to reach API server: {ex.Message}")); - } - } -} - -public record ConnectivityResult(string Status, string? ServerVersion, string Message); diff --git a/src/EntKube.Clusters/Features/CleuraStorage/CleuraStorageEndpoints.cs b/src/EntKube.Clusters/Features/CleuraStorage/CleuraStorageEndpoints.cs deleted file mode 100644 index eb83128..0000000 --- a/src/EntKube.Clusters/Features/CleuraStorage/CleuraStorageEndpoints.cs +++ /dev/null @@ -1,252 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Infrastructure.Cleura; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.CleuraStorage; - -/// -/// Endpoints for managing Cleura S3-compatible object storage credentials. -/// These use the OpenStack Keystone API to create/list/delete EC2 credentials, -/// which serve as S3 access/secret key pairs for the regional S3 endpoint. -/// This replaces the need for MinIO when using Cleura as the cloud provider. -/// -public static class CleuraStorageEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - // POST /api/clusters/{id}/storage/credentials — create new S3 credentials - app.MapPost("/api/clusters/{id:guid}/storage/credentials", async ( - Guid id, - [FromServices] IClusterRepository repository, - [FromServices] CleuraS3Client s3Client, - CancellationToken ct) => - { - // Load cluster and verify it has OpenStack credentials configured. - - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - if (cluster.Provider != CloudProvider.Cleura || cluster.ProviderCredentials is null) - { - return Results.BadRequest(ApiResponse.Fail("Cluster has no Cleura provider configured.")); - } - - ProviderCredentials creds = cluster.ProviderCredentials; - - if (string.IsNullOrEmpty(creds.OpenStackAuthUrl) || string.IsNullOrEmpty(creds.OpenStackProjectId)) - { - return Results.BadRequest(ApiResponse.Fail( - "OpenStack credentials (Auth URL + Project ID) are required for S3 storage.")); - } - - // Authenticate with Keystone to get a scoped token. - - Result sessionResult = await s3Client.AuthenticateKeystoneAsync( - creds.OpenStackAuthUrl, - creds.OpenStackUsername ?? creds.Username, - creds.OpenStackPassword ?? creds.Password, - creds.OpenStackUserDomainName ?? "Default", - creds.OpenStackProjectId, - ct); - - if (sessionResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(sessionResult.Error!)); - } - - // Create a new EC2 credential pair. - - Result credResult = await s3Client.CreateEc2CredentialsAsync( - sessionResult.Value!, ct); - - if (credResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(credResult.Error!)); - } - - Ec2Credential credential = credResult.Value!; - string s3Endpoint = CleuraS3Client.GetS3Endpoint(creds.Region); - - return Results.Ok(ApiResponse.Ok(new S3CredentialResult( - credential.Access, - credential.Secret, - s3Endpoint, - creds.Region, - credential.ProjectId))); - }); - - // GET /api/clusters/{id}/storage/credentials — list existing S3 credentials - app.MapGet("/api/clusters/{id:guid}/storage/credentials", async ( - Guid id, - [FromServices] IClusterRepository repository, - [FromServices] CleuraS3Client s3Client, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - if (cluster.Provider != CloudProvider.Cleura || cluster.ProviderCredentials is null) - { - return Results.BadRequest(ApiResponse.Fail("Cluster has no Cleura provider configured.")); - } - - ProviderCredentials creds = cluster.ProviderCredentials; - - if (string.IsNullOrEmpty(creds.OpenStackAuthUrl) || string.IsNullOrEmpty(creds.OpenStackProjectId)) - { - return Results.BadRequest(ApiResponse.Fail( - "OpenStack credentials required for S3 storage.")); - } - - Result sessionResult = await s3Client.AuthenticateKeystoneAsync( - creds.OpenStackAuthUrl, - creds.OpenStackUsername ?? creds.Username, - creds.OpenStackPassword ?? creds.Password, - creds.OpenStackUserDomainName ?? "Default", - creds.OpenStackProjectId, - ct); - - if (sessionResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(sessionResult.Error!)); - } - - Result> listResult = await s3Client.ListEc2CredentialsAsync( - sessionResult.Value!, ct); - - if (listResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(listResult.Error!)); - } - - string s3Endpoint = CleuraS3Client.GetS3Endpoint(creds.Region); - - List summaries = listResult.Value! - .Select(c => new S3CredentialSummary(c.Access, s3Endpoint, creds.Region, c.ProjectId)) - .ToList(); - - return Results.Ok(ApiResponse.Ok( - new S3CredentialListResult(s3Endpoint, creds.Region, summaries))); - }); - - // DELETE /api/clusters/{id}/storage/credentials/{accessKey} — revoke S3 credentials - app.MapDelete("/api/clusters/{id:guid}/storage/credentials/{accessKey}", async ( - Guid id, - string accessKey, - [FromServices] IClusterRepository repository, - [FromServices] CleuraS3Client s3Client, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - if (cluster.Provider != CloudProvider.Cleura || cluster.ProviderCredentials is null) - { - return Results.BadRequest(ApiResponse.Fail("Cluster has no Cleura provider configured.")); - } - - ProviderCredentials creds = cluster.ProviderCredentials; - - if (string.IsNullOrEmpty(creds.OpenStackAuthUrl) || string.IsNullOrEmpty(creds.OpenStackProjectId)) - { - return Results.BadRequest(ApiResponse.Fail( - "OpenStack credentials required for S3 storage.")); - } - - Result sessionResult = await s3Client.AuthenticateKeystoneAsync( - creds.OpenStackAuthUrl, - creds.OpenStackUsername ?? creds.Username, - creds.OpenStackPassword ?? creds.Password, - creds.OpenStackUserDomainName ?? "Default", - creds.OpenStackProjectId, - ct); - - if (sessionResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(sessionResult.Error!)); - } - - Result deleteResult = await s3Client.DeleteEc2CredentialsAsync( - sessionResult.Value!, accessKey, ct); - - if (deleteResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(deleteResult.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // GET /api/clusters/{id}/storage/info — get S3 connection info without creating credentials - app.MapGet("/api/clusters/{id:guid}/storage/info", async ( - Guid id, - [FromServices] IClusterRepository repository, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - if (cluster.Provider != CloudProvider.Cleura || cluster.ProviderCredentials is null) - { - return Results.Ok(ApiResponse.Ok(new StorageInfoResult( - false, null, null, "No Cleura provider configured."))); - } - - ProviderCredentials creds = cluster.ProviderCredentials; - bool hasOpenStack = !string.IsNullOrEmpty(creds.OpenStackAuthUrl); - - if (!hasOpenStack) - { - return Results.Ok(ApiResponse.Ok(new StorageInfoResult( - false, null, null, "OpenStack credentials not configured. Add them in provider settings."))); - } - - string s3Endpoint = CleuraS3Client.GetS3Endpoint(creds.Region); - - return Results.Ok(ApiResponse.Ok(new StorageInfoResult( - true, s3Endpoint, creds.Region, null))); - }); - } -} - -public record S3CredentialResult( - string AccessKey, - string SecretKey, - string S3Endpoint, - string Region, - string ProjectId); - -public record S3CredentialSummary( - string AccessKey, - string S3Endpoint, - string Region, - string ProjectId); - -public record S3CredentialListResult( - string S3Endpoint, - string Region, - List Credentials); - -public record StorageInfoResult( - bool Available, - string? S3Endpoint, - string? Region, - string? Message); diff --git a/src/EntKube.Clusters/Features/CleuraStorage/StorageBucketEndpoints.cs b/src/EntKube.Clusters/Features/CleuraStorage/StorageBucketEndpoints.cs deleted file mode 100644 index 390cbc1..0000000 --- a/src/EntKube.Clusters/Features/CleuraStorage/StorageBucketEndpoints.cs +++ /dev/null @@ -1,247 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Infrastructure.Cleura; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.CleuraStorage; - -/// -/// Endpoints for managing S3 storage buckets on Cleura's S3-compatible backend. -/// Buckets are created using EC2 credentials (access/secret keys) and tracked -/// on the cluster so components can reference them as storage backends. -/// -/// Flow: User creates credentials (existing endpoint) → creates a bucket with -/// those credentials → bucket is tracked on the cluster → components can wire -/// up to that bucket for their storage needs. -/// -public static class StorageBucketEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - // POST /api/clusters/{id}/storage/buckets — create a new bucket - - app.MapPost("/api/clusters/{id:guid}/storage/buckets", async ( - Guid id, - [FromBody] CreateBucketRequest request, - [FromServices] IClusterRepository repository, - [FromServices] CleuraS3Client s3Client, - CancellationToken ct) => - { - // Load cluster and validate it has Cleura storage configured. - - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - if (cluster.Provider != CloudProvider.Cleura || cluster.ProviderCredentials is null) - { - return Results.BadRequest(ApiResponse.Fail("Cluster has no Cleura provider configured.")); - } - - if (string.IsNullOrWhiteSpace(request.BucketName)) - { - return Results.BadRequest(ApiResponse.Fail("Bucket name is required.")); - } - - ProviderCredentials creds = cluster.ProviderCredentials; - - if (string.IsNullOrEmpty(creds.OpenStackAuthUrl) || string.IsNullOrEmpty(creds.OpenStackProjectId)) - { - return Results.BadRequest(ApiResponse.Fail("OpenStack credentials required for S3 storage.")); - } - - // First authenticate with Keystone to create fresh EC2 credentials - // dedicated to this bucket (one credential pair per bucket for isolation). - - Result sessionResult = await s3Client.AuthenticateKeystoneAsync( - creds.OpenStackAuthUrl, - creds.OpenStackUsername ?? creds.Username, - creds.OpenStackPassword ?? creds.Password, - creds.OpenStackUserDomainName ?? "Default", - creds.OpenStackProjectId, - ct); - - if (sessionResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(sessionResult.Error!)); - } - - // Create a dedicated EC2 credential pair for this bucket. - - Result credResult = await s3Client.CreateEc2CredentialsAsync( - sessionResult.Value!, ct); - - if (credResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(credResult.Error!)); - } - - Ec2Credential credential = credResult.Value!; - string s3Endpoint = CleuraS3Client.GetS3Endpoint(creds.Region); - - // Create the actual S3 bucket using the new credentials. - - Result createResult = await s3Client.CreateBucketAsync( - s3Endpoint, - credential.Access, - credential.Secret, - creds.Region, - request.BucketName, - request.Encrypted, - ct); - - if (createResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(createResult.Error!)); - } - - // Track the bucket on the cluster with its dedicated credentials. - - StorageBucket bucket = StorageBucket.Create( - request.BucketName, - credential.Access, - credential.Secret, - s3Endpoint, - creds.Region, - request.Encrypted); - - cluster.AddStorageBucket(bucket); - await repository.UpdateAsync(cluster, ct); - - return Results.Ok(ApiResponse.Ok(new StorageBucketDto( - bucket.Id, - bucket.Name, - bucket.AccessKey, - bucket.SecretKey, - bucket.Endpoint, - bucket.Region, - bucket.Encrypted, - bucket.CreatedAt))); - }); - - // GET /api/clusters/{id}/storage/buckets — list all tracked buckets - - app.MapGet("/api/clusters/{id:guid}/storage/buckets", async ( - Guid id, - [FromServices] IClusterRepository repository, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - List buckets = cluster.StorageBuckets - .Select(b => new StorageBucketSummaryDto( - b.Id, b.Name, b.Endpoint, b.Region, b.Encrypted, b.CreatedAt)) - .ToList(); - - return Results.Ok(ApiResponse>.Ok(buckets)); - }); - - // GET /api/clusters/{id}/storage/buckets/{bucketId} — get bucket details (including secrets) - - app.MapGet("/api/clusters/{id:guid}/storage/buckets/{bucketId:guid}", async ( - Guid id, - Guid bucketId, - [FromServices] IClusterRepository repository, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - StorageBucket? bucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == bucketId); - - if (bucket is null) - { - return Results.NotFound(ApiResponse.Fail($"Bucket {bucketId} not found.")); - } - - return Results.Ok(ApiResponse.Ok(new StorageBucketDto( - bucket.Id, - bucket.Name, - bucket.AccessKey, - bucket.SecretKey, - bucket.Endpoint, - bucket.Region, - bucket.Encrypted, - bucket.CreatedAt))); - }); - - // DELETE /api/clusters/{id}/storage/buckets/{bucketId} — delete a bucket - - app.MapDelete("/api/clusters/{id:guid}/storage/buckets/{bucketId:guid}", async ( - Guid id, - Guid bucketId, - [FromServices] IClusterRepository repository, - [FromServices] CleuraS3Client s3Client, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - StorageBucket? bucket = cluster.StorageBuckets.FirstOrDefault(b => b.Id == bucketId); - - if (bucket is null) - { - return Results.NotFound(ApiResponse.Fail($"Bucket {bucketId} not found.")); - } - - // Delete the actual S3 bucket using its dedicated credentials. - - Result deleteResult = await s3Client.DeleteBucketAsync( - bucket.Endpoint, - bucket.AccessKey, - bucket.SecretKey, - bucket.Region, - bucket.Name, - ct); - - if (deleteResult.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(deleteResult.Error!)); - } - - // Remove from cluster tracking and persist. - - cluster.RemoveStorageBucket(bucketId); - await repository.UpdateAsync(cluster, ct); - - return Results.Ok(ApiResponse.Ok("Bucket deleted.")); - }); - } -} - -public record CreateBucketRequest(string BucketName, bool Encrypted); - -public record StorageBucketDto( - Guid Id, - string Name, - string AccessKey, - string SecretKey, - string Endpoint, - string Region, - bool Encrypted, - DateTimeOffset CreatedAt); - -public record StorageBucketSummaryDto( - Guid Id, - string Name, - string Endpoint, - string Region, - bool Encrypted, - DateTimeOffset CreatedAt); diff --git a/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthEndpoint.cs b/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthEndpoint.cs deleted file mode 100644 index eff439d..0000000 --- a/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.ClusterHealth; - -/// -/// Maps GET /api/clusters/{id}/health — returns the current health report for -/// a cluster by querying its Prometheus instance for key metrics. -/// -public static class ClusterHealthEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/clusters/{id:guid}/health", async ( - Guid id, - [FromServices] ClusterHealthHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthHandler.cs b/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthHandler.cs deleted file mode 100644 index 5bfbb70..0000000 --- a/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthHandler.cs +++ /dev/null @@ -1,208 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.ClusterHealth; - -/// -/// Queries the cluster's Prometheus instance(s) for key health metrics and -/// assembles a health report. If multiple Prometheus endpoints exist, we use -/// the first one that responds (they should all have the same cluster metrics -/// since they scrape the same targets via kube-state-metrics and node-exporter). -/// -/// The handler evaluates thresholds to determine the overall status: -/// - Healthy: all metrics within normal operating ranges -/// - Degraded: some indicators elevated but cluster still functional -/// - Critical: severe issues needing immediate attention -/// -public class ClusterHealthHandler -{ - private readonly IClusterRepository repository; - private readonly IPrometheusQueryClient queryClient; - - public ClusterHealthHandler(IClusterRepository repository, IPrometheusQueryClient queryClient) - { - this.repository = repository; - this.queryClient = queryClient; - } - - public async Task> HandleAsync(Guid clusterId, CancellationToken ct = default) - { - // Find the cluster and verify it has Prometheus endpoints to query. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - if (cluster.PrometheusEndpoints.Count == 0) - { - return Result.Failure( - "No Prometheus endpoints discovered for this cluster. Run Prometheus discovery first."); - } - - // Select the actual Prometheus server endpoint. The discovery scanner - // stores all metrics-related services (exporters, Grafana, Prometheus). - // We pick the one whose service name contains "prometheus" and listens on - // port 9090, which is the standard Prometheus query API. - - PrometheusEndpoint? prometheusEndpoint = cluster.PrometheusEndpoints - .FirstOrDefault(e => e.ServiceName.Contains("prometheus", StringComparison.OrdinalIgnoreCase) - && e.Url.Contains(":9090")) - ?? cluster.PrometheusEndpoints - .FirstOrDefault(e => e.ServiceName.Contains("prometheus", StringComparison.OrdinalIgnoreCase)) - ?? cluster.PrometheusEndpoints[0]; - - string prometheusUrl = prometheusEndpoint.Url; - - // Query all the health metrics from Prometheus. Each query returns a single - // scalar value. If a query returns null, we default to 0 (metric not available). - // - // Important: kube_pod_status_phase and kube_node_status_condition are gauges - // with value 1 (true) or 0 (false). Using count() would count ALL time series - // regardless of value. We use sum() to count only those with value=1. - - int totalNodes = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "count(kube_node_info)", ct) ?? 0); - - int readyNodes = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})", ct) ?? 0); - - double cpuPercent = await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100", ct) ?? 0; - - double memoryPercent = await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100", ct) ?? 0; - - int runningPods = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(kube_pod_status_phase{phase=\"Running\"})", ct) ?? 0); - - int pendingPods = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(kube_pod_status_phase{phase=\"Pending\"})", ct) ?? 0); - - int failedPods = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(kube_pod_status_phase{phase=\"Failed\"})", ct) ?? 0); - - int restarts = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(increase(kube_pod_container_status_restarts_total[1h]))", ct) ?? 0); - - double apiErrorRate = await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100", ct) ?? 0; - - int diskPressureNodes = (int)(await queryClient.QueryScalarAsync( - cluster, prometheusUrl, "sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})", ct) ?? 0); - - // Determine overall status based on threshold evaluation. - - ClusterHealthStatus status = EvaluateOverallStatus( - totalNodes, readyNodes, cpuPercent, memoryPercent, - pendingPods, failedPods, restarts, apiErrorRate, diskPressureNodes); - - ClusterHealthReport report = new() - { - TotalNodes = totalNodes, - ReadyNodes = readyNodes, - CpuUtilizationPercent = Math.Round(cpuPercent, 1), - MemoryUtilizationPercent = Math.Round(memoryPercent, 1), - RunningPods = runningPods, - PendingPods = pendingPods, - FailedPods = failedPods, - ContainerRestartsLastHour = restarts, - ApiServerErrorRatePercent = Math.Round(apiErrorRate, 2), - NodesWithDiskPressure = diskPressureNodes, - OverallStatus = status, - CheckedAt = DateTimeOffset.UtcNow - }; - - return Result.Success(report); - } - - /// - /// Evaluates the overall cluster health status based on metric thresholds. - /// - /// Critical triggers (any one is enough): - /// - More than 50% of nodes are NOT ready - /// - API server error rate > 10% - /// - Memory utilization > 90% - /// - /// Degraded triggers (any one is enough): - /// - Any node not ready (but less than 50%) - /// - CPU utilization > 80% - /// - Memory utilization > 75% - /// - Pending pods > 5 - /// - Failed pods > 0 - /// - Container restarts > 10 in last hour - /// - API server error rate > 1% - /// - Any node with disk pressure - /// - private static ClusterHealthStatus EvaluateOverallStatus( - int totalNodes, int readyNodes, double cpuPercent, double memoryPercent, - int pendingPods, int failedPods, int restarts, double apiErrorRate, int diskPressureNodes) - { - // Critical: cluster in serious trouble. - - double nodeReadyRatio = totalNodes > 0 ? (double)readyNodes / totalNodes : 1.0; - - if (nodeReadyRatio < 0.5) - { - return ClusterHealthStatus.Critical; - } - - if (apiErrorRate > 10.0) - { - return ClusterHealthStatus.Critical; - } - - if (memoryPercent > 90.0) - { - return ClusterHealthStatus.Critical; - } - - // Degraded: some signs of stress but still operational. - - if (readyNodes < totalNodes) - { - return ClusterHealthStatus.Degraded; - } - - if (cpuPercent > 80.0) - { - return ClusterHealthStatus.Degraded; - } - - if (memoryPercent > 75.0) - { - return ClusterHealthStatus.Degraded; - } - - if (pendingPods > 5) - { - return ClusterHealthStatus.Degraded; - } - - if (failedPods > 0) - { - return ClusterHealthStatus.Degraded; - } - - if (restarts > 10) - { - return ClusterHealthStatus.Degraded; - } - - if (apiErrorRate > 1.0) - { - return ClusterHealthStatus.Degraded; - } - - if (diskPressureNodes > 0) - { - return ClusterHealthStatus.Degraded; - } - - // All good! - - return ClusterHealthStatus.Healthy; - } -} diff --git a/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthReport.cs b/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthReport.cs deleted file mode 100644 index 675550e..0000000 --- a/src/EntKube.Clusters/Features/ClusterHealth/ClusterHealthReport.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Text.Json.Serialization; - -namespace EntKube.Clusters.Features.ClusterHealth; - -/// -/// A point-in-time health report for a Kubernetes cluster, derived from -/// Prometheus metrics. Captures the key indicators that tell you whether -/// the cluster is operating normally, showing signs of stress, or in trouble. -/// -public record ClusterHealthReport -{ - // --- Node health --- - - public int TotalNodes { get; init; } - public int ReadyNodes { get; init; } - - // --- Resource utilization --- - - public double CpuUtilizationPercent { get; init; } - public double MemoryUtilizationPercent { get; init; } - - // --- Workload status --- - - public int RunningPods { get; init; } - public int PendingPods { get; init; } - public int FailedPods { get; init; } - public int ContainerRestartsLastHour { get; init; } - - // --- Control plane health --- - - public double ApiServerErrorRatePercent { get; init; } - - // --- Storage pressure --- - - public int NodesWithDiskPressure { get; init; } - - // --- Overall assessment --- - - [JsonConverter(typeof(JsonStringEnumConverter))] - public ClusterHealthStatus OverallStatus { get; init; } - public DateTimeOffset CheckedAt { get; init; } -} - -/// -/// Overall cluster health status derived from individual metrics. -/// Healthy = everything within normal ranges. -/// Degraded = some indicators outside normal but cluster is operational. -/// Critical = severe issues requiring immediate attention. -/// -public enum ClusterHealthStatus -{ - Healthy, - Degraded, - Critical -} diff --git a/src/EntKube.Clusters/Features/ClusterHealth/IPrometheusQueryClient.cs b/src/EntKube.Clusters/Features/ClusterHealth/IPrometheusQueryClient.cs deleted file mode 100644 index e3a87a4..0000000 --- a/src/EntKube.Clusters/Features/ClusterHealth/IPrometheusQueryClient.cs +++ /dev/null @@ -1,20 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.ClusterHealth; - -/// -/// Queries a Prometheus instance using PromQL and returns scalar results. -/// This is the abstraction that lets us swap in a fake for testing while -/// the real implementation makes HTTP calls to the Prometheus query API -/// through the Kubernetes API service proxy. -/// -public interface IPrometheusQueryClient -{ - /// - /// Executes an instant PromQL query against the given Prometheus URL - /// using the cluster's kubeconfig to proxy through the K8s API server. - /// Returns the scalar value from the result. Returns null if the - /// query fails or returns no data (empty vector, NaN, etc.). - /// - Task QueryScalarAsync(KubernetesCluster cluster, string prometheusUrl, string promql, CancellationToken ct = default); -} diff --git a/src/EntKube.Clusters/Features/ClusterHealth/KubernetesPrometheusQueryClient.cs b/src/EntKube.Clusters/Features/ClusterHealth/KubernetesPrometheusQueryClient.cs deleted file mode 100644 index 98f93e5..0000000 --- a/src/EntKube.Clusters/Features/ClusterHealth/KubernetesPrometheusQueryClient.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Text; -using System.Text.Json; -using EntKube.Clusters.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.ClusterHealth; - -/// -/// Queries Prometheus via the Kubernetes API service proxy. Since the EntKube -/// Clusters service runs outside the target cluster, we can't reach in-cluster -/// Prometheus directly. Instead, we route queries through the K8s API server's -/// service proxy — the same mechanism used during discovery. -/// -/// The proxy URL format is: -/// {apiserver}/api/v1/namespaces/{ns}/services/{svc}:{port}/proxy/api/v1/query?query={promql} -/// -/// We parse the stored PrometheusEndpoint URL to extract namespace, service name, -/// and port, then build the proxy path through the cluster's API server. -/// -public class KubernetesPrometheusQueryClient : IPrometheusQueryClient -{ - private readonly ILogger logger; - - public KubernetesPrometheusQueryClient(ILogger logger) - { - this.logger = logger; - } - - /// - /// Executes an instant PromQL query against Prometheus via the K8s API proxy. - /// Builds a fresh K8s client from the cluster's kubeconfig on each call — same - /// approach that works reliably in the KubernetesPrometheusScanner. - /// - public async Task QueryScalarAsync(KubernetesCluster cluster, string prometheusUrl, string promql, CancellationToken ct = default) - { - try - { - // Parse the in-cluster URL to extract service name, namespace, and port. - // Format: http://{serviceName}.{namespace}.svc.cluster.local:{port} - - Uri uri = new(prometheusUrl); - string host = uri.Host; - int port = uri.Port; - - string[] hostParts = host.Split('.'); - string serviceName = hostParts[0]; - string ns = hostParts.Length > 1 ? hostParts[1] : "default"; - - // Build a Kubernetes client from the cluster's kubeconfig — same approach - // that works in the scanner. - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(cluster.KubeConfig)); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - stream, - currentContext: cluster.ContextName); - - Kubernetes k8sClient = new(config); - Uri baseUri = k8sClient.BaseUri; - - // Build absolute proxy URL to the Prometheus query API. - - string proxyUrl = $"{baseUri.ToString().TrimEnd('/')}/api/v1/namespaces/{ns}/services/{serviceName}:{port}/proxy/api/v1/query"; - string fullUrl = $"{proxyUrl}?query={Uri.EscapeDataString(promql)}"; - - logger.LogDebug("PromQL query: {Url}", fullUrl); - - using HttpRequestMessage request = new(HttpMethod.Get, new Uri(fullUrl)); - using HttpResponseMessage response = await k8sClient.HttpClient.SendAsync(request, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - logger.LogWarning("Prometheus query failed with {StatusCode} for [{Query}]: {Body}", - (int)response.StatusCode, promql, errorBody[..Math.Min(200, errorBody.Length)]); - return null; - } - - string body = await response.Content.ReadAsStringAsync(ct); - double? result = ParsePrometheusScalarResult(body); - - logger.LogDebug("PromQL [{Query}] => {Result}", promql, result); - return result; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to execute PromQL query: {Query}", promql); - return null; - } - } - - /// - /// Parses the standard Prometheus HTTP API response and extracts a single - /// scalar value from an instant vector result. - /// - private static double? ParsePrometheusScalarResult(string responseBody) - { - try - { - using JsonDocument doc = JsonDocument.Parse(responseBody); - JsonElement root = doc.RootElement; - - if (root.GetProperty("status").GetString() != "success") - { - return null; - } - - JsonElement data = root.GetProperty("data"); - string resultType = data.GetProperty("resultType").GetString() ?? ""; - JsonElement resultArray = data.GetProperty("result"); - - if (resultArray.GetArrayLength() == 0) - { - return null; - } - - // For scalar type: data.result is [timestamp, "value"] - // For vector type: data.result is [{"metric":{...}, "value":[timestamp, "value"]}] - - if (resultType == "scalar") - { - string? valueStr = resultArray[1].GetString(); - - if (valueStr is not null && double.TryParse(valueStr, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double scalar)) - { - return double.IsNaN(scalar) ? null : scalar; - } - } - else if (resultType == "vector") - { - JsonElement firstResult = resultArray[0]; - JsonElement valueArray = firstResult.GetProperty("value"); - string? valueStr = valueArray[1].GetString(); - - if (valueStr is not null && double.TryParse(valueStr, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out double vectorValue)) - { - return double.IsNaN(vectorValue) ? null : vectorValue; - } - } - } - catch (Exception) - { - // Malformed response — return null. - } - - return null; - } -} diff --git a/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsContracts.cs b/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsContracts.cs deleted file mode 100644 index f35fbff..0000000 --- a/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsContracts.cs +++ /dev/null @@ -1,38 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.ClusterSettings; - -/// -/// Reads cluster-level settings from the live Kubernetes cluster. Currently this -/// means extracting the allowed container registries from the Kyverno -/// restrict-image-registries ClusterPolicy. As more editable cluster settings -/// emerge, new methods can be added here. -/// -public interface IClusterSettingsReader -{ - Task> ReadAllowedRegistriesAsync(KubernetesCluster cluster, CancellationToken ct = default); -} - -/// -/// Writes cluster-level settings back to the live Kubernetes cluster. For allowed -/// registries this re-applies the Kyverno ClusterPolicy with the updated list. -/// -public interface IClusterSettingsWriter -{ - Task UpdateAllowedRegistriesAsync(KubernetesCluster cluster, List registries, CancellationToken ct = default); -} - -/// -/// The cluster settings as read from the live cluster. Designed as a bag of -/// different setting groups — today just allowed registries, tomorrow maybe -/// excluded namespaces, default resource quotas, etc. -/// -public record ClusterSettingsDto( - List AllowedRegistries); - -/// -/// Request to update cluster settings. Only non-null fields are applied. -/// -public record UpdateClusterSettingsRequest( - List? AllowedRegistries = null); diff --git a/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsEndpoint.cs b/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsEndpoint.cs deleted file mode 100644 index 62ae5ff..0000000 --- a/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsEndpoint.cs +++ /dev/null @@ -1,51 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.ClusterSettings; - -/// -/// Maps the cluster settings endpoints: -/// GET /api/clusters/{id}/settings — read current settings from the live cluster -/// PUT /api/clusters/{id}/settings — update settings on the live cluster -/// -/// These endpoints let the UI's Settings tab fetch and save cluster-level -/// configuration like allowed container registries without going through -/// the component configure flow. -/// -public static class ClusterSettingsEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/clusters/{id:guid}/settings", async ( - Guid id, - [FromServices] GetClusterSettingsHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - app.MapPut("/api/clusters/{id:guid}/settings", async ( - Guid id, - [FromBody] UpdateClusterSettingsRequest request, - [FromServices] UpdateClusterSettingsHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(new { })); - }); - } -} diff --git a/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsHandlers.cs b/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsHandlers.cs deleted file mode 100644 index c56a193..0000000 --- a/src/EntKube.Clusters/Features/ClusterSettings/ClusterSettingsHandlers.cs +++ /dev/null @@ -1,104 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.ClusterSettings; - -/// -/// When someone navigates to the Settings tab on a cluster detail page, we need -/// to show them the current configuration — things like which container registries -/// are allowed. This handler loads the cluster, checks it's reachable, then reads -/// the live settings from the Kubernetes API. -/// -public class GetClusterSettingsHandler -{ - private readonly IClusterRepository repository; - private readonly IClusterSettingsReader reader; - - public GetClusterSettingsHandler(IClusterRepository repository, IClusterSettingsReader reader) - { - this.repository = repository; - this.reader = reader; - } - - public async Task> HandleAsync(Guid clusterId, CancellationToken ct = default) - { - // Find the cluster — can't read settings from something that doesn't exist. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - // The cluster must be connected so we can query its Kubernetes API. - - if (cluster.Status != ClusterStatus.Connected) - { - return Result.Failure( - "Cluster must be connected to read settings."); - } - - // Read the allowed registries from the live Kyverno policy on the cluster. - - List registries = await reader.ReadAllowedRegistriesAsync(cluster, ct); - - return Result.Success(new ClusterSettingsDto(AllowedRegistries: registries)); - } -} - -/// -/// Updates cluster-level settings. The operator edits values in the Settings tab -/// and this handler validates the input, then writes the changes to the live -/// cluster. For allowed registries, this re-applies the Kyverno ClusterPolicy -/// with the new list. -/// -public class UpdateClusterSettingsHandler -{ - private readonly IClusterRepository repository; - private readonly IClusterSettingsWriter writer; - - public UpdateClusterSettingsHandler(IClusterRepository repository, IClusterSettingsWriter writer) - { - this.repository = repository; - this.writer = writer; - } - - public async Task HandleAsync(Guid clusterId, UpdateClusterSettingsRequest request, CancellationToken ct = default) - { - // Find the cluster. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{clusterId}' was not found."); - } - - // Must be connected to write settings. - - if (cluster.Status != ClusterStatus.Connected) - { - return Result.Failure("Cluster must be connected to update settings."); - } - - // If allowed registries are being updated, validate them. - - if (request.AllowedRegistries is not null) - { - if (request.AllowedRegistries.Count == 0) - { - return Result.Failure("Allowed registries must contain at least one registry."); - } - - Result registryResult = await writer.UpdateAllowedRegistriesAsync(cluster, request.AllowedRegistries, ct); - - if (registryResult.IsFailure) - { - return registryResult; - } - } - - return Result.Success(); - } -} diff --git a/src/EntKube.Clusters/Features/ClusterSettings/KyvernoClusterSettingsProvider.cs b/src/EntKube.Clusters/Features/ClusterSettings/KyvernoClusterSettingsProvider.cs deleted file mode 100644 index 1e60fb4..0000000 --- a/src/EntKube.Clusters/Features/ClusterSettings/KyvernoClusterSettingsProvider.cs +++ /dev/null @@ -1,339 +0,0 @@ -using System.Text; -using System.Text.Json; -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies; -using EntKube.SharedKernel.Domain; -using k8s; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.ClusterSettings; - -/// -/// Reads and writes cluster settings by interacting with the live Kubernetes API. -/// For allowed registries, this reads the Kyverno "restrict-image-registries" -/// ClusterPolicy and extracts the registry list from its validation rules. When -/// updating, it patches the policy with the new list. -/// -/// If the policy doesn't exist (security policies not installed), the reader -/// returns an empty list and the writer returns a failure telling the user to -/// install security policies first. -/// -public class KyvernoClusterSettingsProvider : IClusterSettingsReader, IClusterSettingsWriter -{ - private readonly ILogger logger; - - /// - /// The default registries used when the policy doesn't exist or can't be parsed. - /// Matches the defaults in SecurityPoliciesInstaller. - /// - private static readonly List DefaultRegistries = new() - { - "docker.io", "ghcr.io", "registry.k8s.io", "quay.io", "mcr.microsoft.com" - }; - - public KyvernoClusterSettingsProvider(ILogger logger) - { - this.logger = logger; - } - - /// - /// Reads the allowed container registries from the Kyverno restrict-image-registries - /// ClusterPolicy. The registries are stored in the deny conditions of the - /// validate-image-registry rule — each condition has a "value" field like "docker.io/*". - /// We strip the trailing "/*" to get the clean registry name. - /// - public async Task> ReadAllowedRegistriesAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - try - { - Kubernetes client = BuildClient(cluster); - - // Fetch the restrict-image-registries ClusterPolicy from the cluster. - - object result = await client.CustomObjects.GetClusterCustomObjectAsync( - group: "kyverno.io", - version: "v1", - plural: "clusterpolicies", - name: "restrict-image-registries", - cancellationToken: ct); - - string json = JsonSerializer.Serialize(result); - - return ExtractRegistriesFromPolicyJson(json); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Policy doesn't exist — security policies haven't been installed yet. - - logger.LogInformation("restrict-image-registries policy not found on cluster {ClusterId}", cluster.Id); - return new List(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to read allowed registries from cluster {ClusterId}", cluster.Id); - return new List(); - } - } - - /// - /// Updates the allowed registries by replacing the restrict-image-registries - /// ClusterPolicy with a new version containing the updated list. Uses an atomic - /// replace (with resourceVersion) instead of delete+create to avoid any window - /// where no policy exists on the cluster. - /// - public async Task UpdateAllowedRegistriesAsync(KubernetesCluster cluster, List registries, CancellationToken ct = default) - { - try - { - Kubernetes client = BuildClient(cluster); - - // Read the current policy — we need its resourceVersion for the atomic - // replace, and its validationFailureAction to preserve the current mode. - - object existing; - - try - { - existing = await client.CustomObjects.GetClusterCustomObjectAsync( - group: "kyverno.io", - version: "v1", - plural: "clusterpolicies", - name: "restrict-image-registries", - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return Result.Failure("Security policies are not installed. Install the security-policies component first."); - } - - string existingJson = JsonSerializer.Serialize(existing); - string validationAction = ExtractValidationAction(existingJson); - string resourceVersion = ExtractResourceVersion(existingJson); - - // Build the updated policy with the new registries list. - - string registryMessage = $"Images must come from an approved registry. Allowed: {string.Join(", ", registries)}"; - - object policy = BuildRegistryPolicy(registries, registryMessage, validationAction); - - // Set resourceVersion on the replacement so Kubernetes accepts the update. - // This ensures we don't accidentally overwrite a concurrent change. - - if (policy is Dictionary policyDict - && policyDict["metadata"] is Dictionary metadata) - { - metadata["resourceVersion"] = resourceVersion; - } - - await client.CustomObjects.ReplaceClusterCustomObjectAsync( - body: policy, - group: "kyverno.io", - version: "v1", - plural: "clusterpolicies", - name: "restrict-image-registries", - cancellationToken: ct); - - logger.LogInformation("Updated allowed registries on cluster {ClusterId}: {Registries}", - cluster.Id, string.Join(", ", registries)); - - return Result.Success(); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to update allowed registries on cluster {ClusterId}", cluster.Id); - return Result.Failure($"Failed to update allowed registries: {ex.Message}"); - } - } - - /// - /// Parses the restrict-image-registries policy JSON and extracts the allowed - /// registries from the deny conditions. Each condition has a "value" field - /// like "docker.io/*" — we strip the "/*" suffix to get the registry name. - /// - public static List ExtractRegistriesFromPolicyJson(string json) - { - List registries = new(); - - try - { - using JsonDocument doc = JsonDocument.Parse(json); - - // Navigate: spec → rules[0] → validate → foreach[0] → deny → conditions → all[] - // Each item has a "value" like "docker.io/*" - - if (doc.RootElement.TryGetProperty("spec", out JsonElement spec) - && spec.TryGetProperty("rules", out JsonElement rules) - && rules.GetArrayLength() > 0) - { - JsonElement rule = rules[0]; - - if (rule.TryGetProperty("validate", out JsonElement validate) - && validate.TryGetProperty("foreach", out JsonElement forEach) - && forEach.GetArrayLength() > 0) - { - JsonElement foreachItem = forEach[0]; - - if (foreachItem.TryGetProperty("deny", out JsonElement deny) - && deny.TryGetProperty("conditions", out JsonElement conditions) - && conditions.TryGetProperty("all", out JsonElement all)) - { - foreach (JsonElement condition in all.EnumerateArray()) - { - if (condition.TryGetProperty("value", out JsonElement value)) - { - string? val = value.GetString(); - - if (val is not null && val.EndsWith("/*")) - { - registries.Add(val[..^2]); - } - } - } - } - } - } - } - catch - { - // If parsing fails, return empty — caller will use defaults or show empty. - } - - return registries; - } - - /// - /// Extracts the validationFailureAction from the policy JSON so we preserve - /// it when re-applying the policy with updated registries. - /// - private static string ExtractValidationAction(string json) - { - try - { - using JsonDocument doc = JsonDocument.Parse(json); - - if (doc.RootElement.TryGetProperty("spec", out JsonElement spec) - && spec.TryGetProperty("validationFailureAction", out JsonElement action)) - { - return action.GetString() ?? "Audit"; - } - } - catch - { - // Fall through to default. - } - - return "Audit"; - } - - /// - /// Extracts the metadata.resourceVersion from the policy JSON so we can - /// perform an atomic replace via the Kubernetes API. - /// - private static string ExtractResourceVersion(string json) - { - try - { - using JsonDocument doc = JsonDocument.Parse(json); - - if (doc.RootElement.TryGetProperty("metadata", out JsonElement metadata) - && metadata.TryGetProperty("resourceVersion", out JsonElement rv)) - { - return rv.GetString() ?? ""; - } - } - catch - { - // Fall through to empty. - } - - return ""; - } - - /// - /// Builds the Kyverno ClusterPolicy object for restrict-image-registries. - /// This mirrors the exact structure from SecurityPoliciesInstaller — including - /// the exclude block for system namespaces and the correct labels — so the - /// policy created by the Settings tab is identical to one created by the - /// installer. Without the exclude block, saving registries would produce a - /// policy that enforces on system namespaces and diverges from the installer. - /// - internal static object BuildRegistryPolicy(List registries, string message, string validationAction) - { - return new Dictionary - { - ["apiVersion"] = "kyverno.io/v1", - ["kind"] = "ClusterPolicy", - ["metadata"] = new Dictionary - { - ["name"] = "restrict-image-registries", - ["labels"] = new Dictionary - { - ["app.kubernetes.io/part-of"] = "security-policies", - ["app.kubernetes.io/managed-by"] = "entkube" - }, - ["annotations"] = new Dictionary - { - ["policies.kyverno.io/title"] = "Restrict Image Registries", - ["policies.kyverno.io/description"] = "Only images from approved registries are allowed" - } - }, - ["spec"] = new Dictionary - { - ["validationFailureAction"] = validationAction, - ["background"] = true, - ["rules"] = new List - { - new Dictionary - { - ["name"] = "validate-image-registry", - ["match"] = new Dictionary - { - ["any"] = new List - { - new Dictionary - { - ["resources"] = new Dictionary - { - ["kinds"] = new List { "Pod" } - } - } - } - }, - ["exclude"] = SecurityPoliciesInstaller.BuildExcludeBlock(), - ["validate"] = new Dictionary - { - ["message"] = message, - ["foreach"] = new List - { - new Dictionary - { - ["list"] = "request.object.spec.[initContainers, containers, ephemeralContainers][]", - ["deny"] = new Dictionary - { - ["conditions"] = new Dictionary - { - ["all"] = registries.Select(reg => (object)new Dictionary - { - ["key"] = "{{ element.image }}", - ["operator"] = "NotEquals", - ["value"] = $"{reg}/*" - }).ToList() - } - } - } - } - } - } - } - } - }; - } - - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(cluster.KubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, cluster.ContextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Clusters/Features/DeleteCluster/DeleteClusterEndpoint.cs b/src/EntKube.Clusters/Features/DeleteCluster/DeleteClusterEndpoint.cs deleted file mode 100644 index 58a52b2..0000000 --- a/src/EntKube.Clusters/Features/DeleteCluster/DeleteClusterEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.DeleteCluster; - -/// -/// Maps DELETE /api/clusters/{id} — removes a cluster from the platform. -/// Returns 204 No Content on success or 404 if the cluster wasn't found. -/// -public static class DeleteClusterEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapDelete("/api/clusters/{id:guid}", async ( - Guid id, - [FromServices] DeleteClusterHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.NoContent(); - }); - } -} diff --git a/src/EntKube.Clusters/Features/DeleteCluster/DeleteClusterHandler.cs b/src/EntKube.Clusters/Features/DeleteCluster/DeleteClusterHandler.cs deleted file mode 100644 index a299ef4..0000000 --- a/src/EntKube.Clusters/Features/DeleteCluster/DeleteClusterHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.DeleteCluster; - -/// -/// Handles removing a cluster from the platform. A tenant admin uses this when -/// they no longer want EntKube to manage a particular cluster. We verify the -/// cluster exists before deleting it — attempting to delete a non-existent cluster -/// is treated as an error so the caller knows something was wrong. -/// -public class DeleteClusterHandler -{ - private readonly IClusterRepository repository; - - public DeleteClusterHandler(IClusterRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(Guid id, CancellationToken ct = default) - { - // First, confirm the cluster exists. Deleting something that doesn't - // exist suggests a stale request or a bug in the caller. - - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{id}' was not found."); - } - - // Remove the cluster from persistence. Any associated resources - // (provisioned services, health checks) should be cleaned up by - // their respective bounded contexts via eventual consistency. - - await repository.DeleteAsync(id, ct); - - return Result.Success(); - } -} diff --git a/src/EntKube.Clusters/Features/DiscoverPrometheus/DiscoverPrometheusEndpoint.cs b/src/EntKube.Clusters/Features/DiscoverPrometheus/DiscoverPrometheusEndpoint.cs deleted file mode 100644 index 410b1e4..0000000 --- a/src/EntKube.Clusters/Features/DiscoverPrometheus/DiscoverPrometheusEndpoint.cs +++ /dev/null @@ -1,57 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.DiscoverPrometheus; - -/// -/// Maps POST /api/clusters/{id}/discover-prometheus — triggers a scan of the -/// cluster for Prometheus services. Returns the list of discovered endpoints. -/// If multiple Prometheus instances exist, all are stored for aggregated querying. -/// -public static class DiscoverPrometheusEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters/{id:guid}/discover-prometheus", async ( - Guid id, - [FromServices] DiscoverPrometheusHandler handler, - CancellationToken ct) => - { - Result> result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - List dtos = result.Value! - .Select(e => new PrometheusEndpointDto(e.Url, e.Namespace, e.ServiceName)) - .ToList(); - - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - - app.MapGet("/api/clusters/{id:guid}/prometheus", async ( - Guid id, - [FromServices] IClusterRepository repository, - CancellationToken ct) => - { - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster '{id}' not found.")); - } - - List dtos = cluster.PrometheusEndpoints - .Select(e => new PrometheusEndpointDto(e.Url, e.Namespace, e.ServiceName)) - .ToList(); - - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - } -} - -public record PrometheusEndpointDto(string Url, string Namespace, string ServiceName); diff --git a/src/EntKube.Clusters/Features/DiscoverPrometheus/DiscoverPrometheusHandler.cs b/src/EntKube.Clusters/Features/DiscoverPrometheus/DiscoverPrometheusHandler.cs deleted file mode 100644 index f47cd05..0000000 --- a/src/EntKube.Clusters/Features/DiscoverPrometheus/DiscoverPrometheusHandler.cs +++ /dev/null @@ -1,50 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.DiscoverPrometheus; - -/// -/// Scans a registered cluster for Prometheus services and stores the discovered -/// endpoints on the cluster aggregate. If multiple Prometheus instances exist -/// (e.g., one per namespace, or a federated setup), all are stored so metrics -/// can be aggregated from all of them during queries. -/// -/// Re-running discovery replaces the previous scan results — this allows the -/// platform to detect when Prometheus instances are added or removed. -/// -public class DiscoverPrometheusHandler -{ - private readonly IClusterRepository repository; - private readonly IPrometheusScanner scanner; - - public DiscoverPrometheusHandler(IClusterRepository repository, IPrometheusScanner scanner) - { - this.repository = repository; - this.scanner = scanner; - } - - public async Task>> HandleAsync(Guid clusterId, CancellationToken ct = default) - { - // Find the cluster we want to scan. - - KubernetesCluster? cluster = await repository.GetByIdAsync(clusterId, ct); - - if (cluster is null) - { - return Result.Failure>($"Cluster with ID '{clusterId}' was not found."); - } - - // Use the scanner to discover Prometheus services in the cluster. - // The scanner uses the cluster's kubeconfig and context to connect. - - List endpoints = await scanner.ScanAsync(cluster, ct); - - // Store the discovered endpoints on the cluster aggregate. - // This replaces any previously discovered endpoints. - - cluster.SetPrometheusEndpoints(endpoints); - await repository.UpdateAsync(cluster, ct); - - return Result.Success(endpoints); - } -} diff --git a/src/EntKube.Clusters/Features/DiscoverPrometheus/IPrometheusScanner.cs b/src/EntKube.Clusters/Features/DiscoverPrometheus/IPrometheusScanner.cs deleted file mode 100644 index 6004476..0000000 --- a/src/EntKube.Clusters/Features/DiscoverPrometheus/IPrometheusScanner.cs +++ /dev/null @@ -1,14 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.DiscoverPrometheus; - -/// -/// Scans a Kubernetes cluster for running Prometheus instances. -/// Looks for services with common Prometheus labels/ports across all namespaces. -/// The implementation uses the Kubernetes API (via the cluster's kubeconfig) -/// to discover services matching Prometheus patterns. -/// -public interface IPrometheusScanner -{ - Task> ScanAsync(KubernetesCluster cluster, CancellationToken ct = default); -} diff --git a/src/EntKube.Clusters/Features/DiscoverPrometheus/KubernetesPrometheusScanner.cs b/src/EntKube.Clusters/Features/DiscoverPrometheus/KubernetesPrometheusScanner.cs deleted file mode 100644 index ab5b55e..0000000 --- a/src/EntKube.Clusters/Features/DiscoverPrometheus/KubernetesPrometheusScanner.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System.Text; -using EntKube.Clusters.Domain; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Features.DiscoverPrometheus; - -/// -/// Scans a Kubernetes cluster for actual running Prometheus instances. -/// We cannot rely on service names or labels alone — anyone can name a service -/// "prometheus" without it being one. Instead, this scanner: -/// -/// 1. Connects to the cluster using the stored kubeconfig -/// 2. Lists ALL services across all namespaces -/// 3. For each service with an HTTP port, proxies through the Kubernetes API -/// to call the service and verify it responds as Prometheus -/// 4. Confirms the service is Prometheus by calling /-/ready or /api/v1/query -/// -/// This approach finds Prometheus regardless of how it was deployed (Helm, -/// operator, manual manifests, custom names) because we verify the actual -/// running software, not metadata about it. -/// -public class KubernetesPrometheusScanner : IPrometheusScanner -{ - private readonly ILogger logger; - - // Per-service probe timeout — don't wait too long on unresponsive services. - - private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(5); - - public KubernetesPrometheusScanner(ILogger logger) - { - this.logger = logger; - } - - /// - /// Scans all namespaces by listing every service in the cluster, then - /// probing each one through the Kubernetes API proxy to confirm whether - /// it's actually a Prometheus instance. Returns all confirmed endpoints. - /// - public async Task> ScanAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - // Build a Kubernetes client from the cluster's stored kubeconfig. - // We use the specific context that was selected during registration. - - Kubernetes client = BuildClient(cluster); - List confirmedEndpoints = new(); - - // Get the base URI so we can construct absolute proxy URLs. - // Using absolute URIs avoids subtle bugs with relative URI resolution - // where BaseAddress trailing-slash presence changes the behavior. - - Uri baseUri = client.BaseUri; - logger.LogInformation("Connecting to cluster {ClusterName} at {BaseUri}", cluster.Name, baseUri); - - // List all services across all namespaces. We don't filter by labels - // because we want to find Prometheus instances regardless of how they - // were deployed or labelled. - - V1ServiceList serviceList; - - try - { - serviceList = await client.CoreV1.ListServiceForAllNamespacesAsync(cancellationToken: ct); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list services in cluster {ClusterName}. Is the cluster reachable?", cluster.Name); - return confirmedEndpoints; - } - - logger.LogInformation("Scanning {ServiceCount} services in cluster {ClusterName} for Prometheus instances", - serviceList.Items.Count, cluster.Name); - - // For each service, check if any of its ports could be Prometheus. - // We focus on HTTP-like ports (named http, web, or common Prometheus ports). - - foreach (V1Service service in serviceList.Items) - { - if (ct.IsCancellationRequested) - { - break; - } - - string ns = service.Metadata.NamespaceProperty ?? "default"; - string serviceName = service.Metadata.Name; - - // Skip the kubernetes API service itself. - - if (serviceName == "kubernetes" && ns == "default") - { - continue; - } - - List candidatePorts = GetCandidatePorts(service); - - if (candidatePorts.Count == 0) - { - continue; - } - - logger.LogInformation("Probing {Service} in {Namespace} on ports [{Ports}]", - serviceName, ns, string.Join(", ", candidatePorts)); - - foreach (int port in candidatePorts) - { - if (ct.IsCancellationRequested) - { - break; - } - - // Try to proxy through the Kubernetes API to reach this service - // and verify it's Prometheus by calling its endpoints. - - bool isPrometheus = await ProbeServiceForPrometheusAsync(client, baseUri, ns, serviceName, port, ct); - - if (isPrometheus) - { - // Build the in-cluster URL that other services can use to reach this Prometheus. - - string prometheusUrl = $"http://{serviceName}.{ns}.svc.cluster.local:{port}"; - - logger.LogInformation("Confirmed Prometheus at {Url}", prometheusUrl); - - confirmedEndpoints.Add(new PrometheusEndpoint(prometheusUrl, ns, serviceName)); - - // Don't check other ports on the same service — we found it. - - break; - } - } - } - - logger.LogInformation("Found {Count} Prometheus instance(s) in cluster {ClusterName}", - confirmedEndpoints.Count, cluster.Name); - - return confirmedEndpoints; - } - - /// - /// Determines which ports on a service are candidates for being Prometheus. - /// We prioritize well-known ports (9090) and HTTP-named ports, but also - /// include any port in the 8000-9999 range as a fallback since Prometheus - /// can be configured on non-standard ports. - /// - private static List GetCandidatePorts(V1Service service) - { - List ports = new(); - - if (service.Spec?.Ports is null) - { - return ports; - } - - foreach (V1ServicePort servicePort in service.Spec.Ports) - { - int port = servicePort.Port; - string? portName = servicePort.Name?.ToLowerInvariant(); - - // Priority 1: Well-known Prometheus port - - if (port == 9090) - { - ports.Insert(0, port); - continue; - } - - // Priority 2: Ports with HTTP-like names (common in Prometheus deployments) - - if (portName is "http" or "http-web" or "web" or "http-metrics" or "metrics") - { - ports.Add(port); - continue; - } - - // Priority 3: Ports in the typical application range - - if (port is >= 8080 and <= 9999) - { - ports.Add(port); - } - } - - return ports; - } - - /// - /// Attempts to reach a service through the Kubernetes API service proxy and verify - /// it's actually Prometheus. The K8s API proxy lets us access cluster-internal - /// services without needing port-forwarding or direct network access. - /// - /// Proxy URL format: - /// {apiserver}/api/v1/namespaces/{ns}/services/{svc}:{port}/proxy/{path} - /// - /// Verification strategy: - /// 1. Call /-/ready — Prometheus returns 200 with "Prometheus Server is Ready." - /// 2. Call /api/v1/query?query=vector(1) — only Prometheus can evaluate PromQL - /// 3. Call /-/healthy — fallback for older versions - /// - private async Task ProbeServiceForPrometheusAsync( - Kubernetes client, - Uri baseUri, - string ns, - string serviceName, - int port, - CancellationToken ct) - { - try - { - // Create a per-probe cancellation token with timeout so one slow service - // doesn't block the entire scan. - - using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(ProbeTimeout); - CancellationToken probeCt = timeoutCts.Token; - - HttpClient httpClient = client.HttpClient; - - // Construct absolute URIs to avoid relative URI resolution issues. - // The Kubernetes service proxy path is well-defined. - - string proxyBase = $"{baseUri.ToString().TrimEnd('/')}/api/v1/namespaces/{ns}/services/{serviceName}:{port}/proxy"; - - // Strategy 1: Try /-/ready endpoint. - // Prometheus 2.x+ responds with 200 and body "Prometheus Server is Ready.\n" - - Uri readyUri = new($"{proxyBase}/-/ready"); - - using (HttpRequestMessage readyRequest = new(HttpMethod.Get, readyUri)) - { - using HttpResponseMessage readyResponse = await httpClient.SendAsync(readyRequest, probeCt); - - logger.LogInformation("Probe {Service}:{Port} in {Namespace} /-/ready => {StatusCode}", - serviceName, port, ns, (int)readyResponse.StatusCode); - - if (readyResponse.IsSuccessStatusCode) - { - string readyBody = await readyResponse.Content.ReadAsStringAsync(probeCt); - - if (readyBody.Contains("Prometheus", StringComparison.OrdinalIgnoreCase)) - { - logger.LogInformation("Confirmed via /-/ready: {Service}:{Port} in {Namespace}", serviceName, port, ns); - return true; - } - - logger.LogInformation("/-/ready returned 200 but body was: {Body}", readyBody.Trim()); - } - } - - // Strategy 2: Try a PromQL query that only Prometheus can answer. - // vector(1) is a trivial PromQL expression that returns a single instant vector. - - Uri queryUri = new($"{proxyBase}/api/v1/query?query=vector(1)"); - - using (HttpRequestMessage queryRequest = new(HttpMethod.Get, queryUri)) - { - using HttpResponseMessage queryResponse = await httpClient.SendAsync(queryRequest, probeCt); - - logger.LogInformation("Probe {Service}:{Port} in {Namespace} /api/v1/query => {StatusCode}", - serviceName, port, ns, (int)queryResponse.StatusCode); - - if (queryResponse.IsSuccessStatusCode) - { - string queryBody = await queryResponse.Content.ReadAsStringAsync(probeCt); - - // A valid Prometheus response contains "status":"success" and "resultType". - - if (queryBody.Contains("\"status\":\"success\"", StringComparison.Ordinal) && - queryBody.Contains("\"resultType\"", StringComparison.Ordinal)) - { - logger.LogInformation("Confirmed via PromQL query: {Service}:{Port} in {Namespace}", serviceName, port, ns); - return true; - } - - logger.LogInformation("/api/v1/query returned 200 but body was: {Body}", queryBody[..Math.Min(200, queryBody.Length)]); - } - } - - // Strategy 3: Try /-/healthy as a last resort. - - Uri healthyUri = new($"{proxyBase}/-/healthy"); - - using (HttpRequestMessage healthyRequest = new(HttpMethod.Get, healthyUri)) - { - using HttpResponseMessage healthyResponse = await httpClient.SendAsync(healthyRequest, probeCt); - - logger.LogInformation("Probe {Service}:{Port} in {Namespace} /-/healthy => {StatusCode}", - serviceName, port, ns, (int)healthyResponse.StatusCode); - - if (healthyResponse.IsSuccessStatusCode) - { - string healthyBody = await healthyResponse.Content.ReadAsStringAsync(probeCt); - - if (healthyBody.Contains("Prometheus", StringComparison.OrdinalIgnoreCase)) - { - logger.LogInformation("Confirmed via /-/healthy: {Service}:{Port} in {Namespace}", serviceName, port, ns); - return true; - } - - logger.LogInformation("/-/healthy returned 200 but body was: {Body}", healthyBody.Trim()); - } - } - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - // Per-probe timeout — service didn't respond fast enough, skip it. - logger.LogInformation("Timeout probing {Service}:{Port} in {Namespace} (>{TimeoutSeconds}s)", - serviceName, port, ns, ProbeTimeout.TotalSeconds); - } - catch (HttpRequestException ex) - { - // Service is not reachable via proxy. - logger.LogInformation("HTTP error probing {Service}:{Port} in {Namespace}: {Message}", serviceName, port, ns, ex.Message); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Unexpected error probing {Service}:{Port} in {Namespace}", serviceName, port, ns); - } - - return false; - } - - /// - /// Creates a Kubernetes client from the cluster's stored kubeconfig, - /// selecting the specific context that was chosen during registration. - /// - private static Kubernetes BuildClient(KubernetesCluster cluster) - { - // Write kubeconfig to a temporary stream for parsing. - - using MemoryStream stream = new(Encoding.UTF8.GetBytes(cluster.KubeConfig)); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - stream, - currentContext: cluster.ContextName); - - return new Kubernetes(config); - } -} - - diff --git a/src/EntKube.Clusters/Features/GetClusterById/GetClusterByIdEndpoint.cs b/src/EntKube.Clusters/Features/GetClusterById/GetClusterByIdEndpoint.cs deleted file mode 100644 index 7937125..0000000 --- a/src/EntKube.Clusters/Features/GetClusterById/GetClusterByIdEndpoint.cs +++ /dev/null @@ -1,87 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.GetClusterById; - -/// -/// Maps GET /api/clusters/{id} — returns the full details for a single cluster, -/// including persisted components from the last adoption scan. The frontend uses -/// this to render the cluster detail page without needing to re-scan every time. -/// -public static class GetClusterByIdEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/clusters/{id:guid}", async ( - Guid id, - [FromServices] GetClusterByIdHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - KubernetesCluster cluster = result.Value!; - - List componentDtos = cluster.Components - .Select(c => new ClusterComponentDto( - c.ComponentName, - c.Status.ToString(), - c.Version, - c.Namespace, - c.HelmReleaseName, - new Dictionary(c.Configuration), - c.LastCheckedAt)) - .ToList(); - - ClusterDetail detail = new( - cluster.Id, - cluster.TenantId, - cluster.Name, - cluster.ApiServerUrl, - cluster.Status.ToString(), - cluster.Provider.ToString(), - cluster.ProviderCredentials?.Username, - cluster.ProviderCredentials?.Region, - cluster.ProviderCredentials?.OpenStackAuthUrl is not null, - cluster.ProviderCredentials?.OpenStackAuthUrl, - cluster.ProviderCredentials?.OpenStackUsername, - cluster.RegisteredAt, - cluster.LastHealthCheckAt, - componentDtos); - - return Results.Ok(ApiResponse.Ok(detail)); - }); - } -} - -public record ClusterDetail( - Guid Id, - Guid TenantId, - string Name, - string ApiServerUrl, - string Status, - string Provider, - string? ProviderUsername, - string? ProviderRegion, - bool HasOpenStackCredentials, - string? OpenStackAuthUrl, - string? OpenStackUsername, - DateTimeOffset RegisteredAt, - DateTimeOffset? LastHealthCheckAt, - List Components); - -public record ClusterComponentDto( - string ComponentName, - string Status, - string? Version, - string? Namespace, - string? HelmReleaseName, - Dictionary Configuration, - DateTimeOffset LastCheckedAt); diff --git a/src/EntKube.Clusters/Features/GetClusterById/GetClusterByIdHandler.cs b/src/EntKube.Clusters/Features/GetClusterById/GetClusterByIdHandler.cs deleted file mode 100644 index d8d48bf..0000000 --- a/src/EntKube.Clusters/Features/GetClusterById/GetClusterByIdHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.GetClusterById; - -/// -/// Retrieves a single cluster by its unique ID. The BFF uses this when a user -/// navigates to a cluster detail view — they need the full aggregate to display -/// connection details, status, and last health check information. -/// -public class GetClusterByIdHandler -{ - private readonly IClusterRepository repository; - - public GetClusterByIdHandler(IClusterRepository repository) - { - this.repository = repository; - } - - public async Task> HandleAsync(Guid id, CancellationToken ct = default) - { - // Look up the cluster in the repository. If it doesn't exist, - // we return a failure result rather than throwing an exception - // since a missing cluster is an expected scenario (e.g., stale links). - - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{id}' was not found."); - } - - return Result.Success(cluster); - } -} diff --git a/src/EntKube.Clusters/Features/GetClusterById/GetClusterCredentialsEndpoint.cs b/src/EntKube.Clusters/Features/GetClusterById/GetClusterCredentialsEndpoint.cs deleted file mode 100644 index 18bbf76..0000000 --- a/src/EntKube.Clusters/Features/GetClusterById/GetClusterCredentialsEndpoint.cs +++ /dev/null @@ -1,40 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.GetClusterById; - -/// -/// Maps GET /api/clusters/{id}/credentials — returns the cluster's kubeconfig and -/// context name. This is an internal-only endpoint used by the BFF to fetch -/// connection details before forwarding requests to the Provisioning service. -/// -/// Not exposed to the frontend — the BFF uses it server-to-server. -/// -public static class GetClusterCredentialsEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/clusters/{id:guid}/credentials", async ( - Guid id, - [FromServices] GetClusterByIdHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - KubernetesCluster cluster = result.Value!; - - ClusterCredentials credentials = new(cluster.KubeConfig, cluster.ContextName); - - return Results.Ok(ApiResponse.Ok(credentials)); - }); - } -} - -public record ClusterCredentials(string KubeConfig, string ContextName); diff --git a/src/EntKube.Clusters/Features/GetClusterById/GetClusterGatewayEndpoint.cs b/src/EntKube.Clusters/Features/GetClusterById/GetClusterGatewayEndpoint.cs deleted file mode 100644 index c661be7..0000000 --- a/src/EntKube.Clusters/Features/GetClusterById/GetClusterGatewayEndpoint.cs +++ /dev/null @@ -1,113 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.GetClusterById; - -/// -/// Maps GET /api/clusters/{id}/gateway — returns the cluster's default ingress -/// gateway reference (name + namespace) resolved from the ingress component's -/// discovered configuration. -/// -/// When a service needs to expose something on a URL (e.g., app routes, monitoring -/// ingress, Keycloak), it calls this endpoint instead of asking the user to enter -/// the gateway name and namespace manually. The cluster already knows which ingress -/// provider is installed and where the gateways live. -/// -/// For Istio clusters with Gateway API: -/// - Returns the "internal" gateway in "internal-ingress" namespace by default -/// - Also reports whether an external gateway is available -/// -/// For Traefik clusters: -/// - Returns provider "traefik" with no gateway ref (Traefik uses IngressRoute CRDs) -/// -/// Not exposed to the frontend — used server-to-server by the Provisioning service. -/// -public static class GetClusterGatewayEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/clusters/{id:guid}/gateway", async ( - Guid id, - [FromServices] GetClusterByIdHandler handler, - CancellationToken ct) => - { - // Fetch the full cluster aggregate so we can inspect its components. - - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - KubernetesCluster cluster = result.Value!; - - // Look up the ingress component — it stores the provider type and - // gateway discovery results from the last adoption scan. - - ClusterComponent? ingressComponent = cluster.Components.FirstOrDefault( - c => c.ComponentName.Equals("ingress", StringComparison.OrdinalIgnoreCase)); - - if (ingressComponent is null || ingressComponent.Status == Features.AdoptCluster.ComponentStatus.NotInstalled) - { - return Results.Ok(ApiResponse.Ok( - new ClusterGatewayInfo(Provider: null, InternalGateway: null, ExternalGateway: null))); - } - - // Extract the provider and gateway details from the component's - // discovered configuration values. - - ingressComponent.Configuration.TryGetValue("provider", out string? provider); - - GatewayRef? internalGateway = null; - GatewayRef? externalGateway = null; - - if (string.Equals(provider, "istio", StringComparison.OrdinalIgnoreCase)) - { - // Istio uses Gateway API — the ingress check stores whether - // internal/external gateways exist. The naming convention is - // well-known: "internal" in "internal-ingress", "external" in - // "external-ingress". - - bool hasInternal = ingressComponent.Configuration.TryGetValue("hasInternalGateway", out string? intGw) - && intGw.Equals("True", StringComparison.OrdinalIgnoreCase); - - bool hasExternal = ingressComponent.Configuration.TryGetValue("hasExternalGateway", out string? extGw) - && extGw.Equals("True", StringComparison.OrdinalIgnoreCase); - - if (hasInternal) - { - internalGateway = new GatewayRef("internal", "internal-ingress"); - } - - if (hasExternal) - { - externalGateway = new GatewayRef("external", "external-ingress"); - } - } - - // For Traefik, no Gateway API gateway refs are needed — Traefik uses - // its own IngressRoute CRDs. The provider info is enough for callers - // to decide how to create routes. - - return Results.Ok(ApiResponse.Ok( - new ClusterGatewayInfo(provider, internalGateway, externalGateway))); - }); - } -} - -/// -/// The cluster's resolved ingress gateway information. Services use this to -/// automatically attach routes to the correct gateway without manual input. -/// -public record ClusterGatewayInfo( - string? Provider, - GatewayRef? InternalGateway, - GatewayRef? ExternalGateway); - -/// -/// A reference to a Gateway API Gateway resource on the cluster. -/// -public record GatewayRef(string Name, string Namespace); diff --git a/src/EntKube.Clusters/Features/GetClusters/GetClustersEndpoint.cs b/src/EntKube.Clusters/Features/GetClusters/GetClustersEndpoint.cs deleted file mode 100644 index 129cd2e..0000000 --- a/src/EntKube.Clusters/Features/GetClusters/GetClustersEndpoint.cs +++ /dev/null @@ -1,64 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.GetClusters; - -/// -/// Lists all registered clusters. The BFF (Web service) calls this endpoint -/// to populate the clusters dashboard. Returns a lightweight summary of each -/// cluster's name, status, and last health check time. -/// -public static class GetClustersEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/clusters", async ( - [FromServices] IClusterRepository repository, - [FromQuery] Guid? tenantId, - [FromQuery] Guid? environmentId, - CancellationToken ct) => - { - IReadOnlyList clusters = await repository.GetAllAsync(ct); - - // If a tenantId filter is provided, only return clusters belonging to that tenant. - - if (tenantId.HasValue && tenantId.Value != Guid.Empty) - { - clusters = clusters.Where(c => c.TenantId == tenantId.Value).ToList().AsReadOnly(); - } - - // If an environmentId filter is provided, narrow down to clusters - // assigned to that environment. This is used by the "Add Environment" - // form on the app detail page — once the user picks an environment, - // the UI fetches only the clusters that belong to it. - - if (environmentId.HasValue && environmentId.Value != Guid.Empty) - { - clusters = clusters.Where(c => c.EnvironmentId == environmentId.Value).ToList().AsReadOnly(); - } - - List summaries = clusters.Select(c => new ClusterSummary( - c.Id, - c.Name, - c.ApiServerUrl, - c.Status.ToString(), - c.Provider.ToString(), - c.TenantId, - c.EnvironmentId, - c.LastHealthCheckAt)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - } -} - -public record ClusterSummary( - Guid Id, - string Name, - string ApiServerUrl, - string Status, - string Provider, - Guid TenantId, - Guid EnvironmentId, - DateTimeOffset? LastHealthCheckAt); diff --git a/src/EntKube.Clusters/Features/InstallPrometheus/HelmPrometheusInstaller.cs b/src/EntKube.Clusters/Features/InstallPrometheus/HelmPrometheusInstaller.cs deleted file mode 100644 index 92d7390..0000000 --- a/src/EntKube.Clusters/Features/InstallPrometheus/HelmPrometheusInstaller.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System.Diagnostics; -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.InstallPrometheus; - -/// -/// Installs kube-prometheus-stack via Helm CLI using the cluster's kubeconfig. -/// Applies enterprise production configuration values including: -/// - High availability (2 Prometheus replicas) -/// - Persistent storage (50Gi retention) -/// - Resource requests/limits -/// - 30-day retention -/// - ServiceMonitor CRDs enabled -/// - Alertmanager in HA mode -/// - Node exporters and kube-state-metrics -/// - Security context hardening -/// -/// The Helm CLI must be available in PATH for this to work. -/// In containerized deployments, the EntKube Clusters image includes helm. -/// -public class HelmPrometheusInstaller : IHelmPrometheusInstaller -{ - private readonly ILogger logger; - - public HelmPrometheusInstaller(ILogger logger) - { - this.logger = logger; - } - - public async Task InstallAsync( - KubernetesCluster cluster, - string releaseName, - string targetNamespace, - CancellationToken ct = default) - { - // Write the kubeconfig to a temporary file so Helm can authenticate. - - string kubeconfigPath = Path.Combine(Path.GetTempPath(), $"kubeconfig-{cluster.Id}.yaml"); - - try - { - await File.WriteAllTextAsync(kubeconfigPath, cluster.KubeConfig, ct); - - // First ensure the prometheus-community repo is available. - - await RunHelmAsync($"repo add prometheus-community https://prometheus-community.github.io/helm-charts", kubeconfigPath, cluster.ContextName, ct); - await RunHelmAsync("repo update", kubeconfigPath, cluster.ContextName, ct); - - // Install kube-prometheus-stack with enterprise production values. - - string valuesYaml = GetProductionValues(); - string valuesPath = Path.Combine(Path.GetTempPath(), $"prom-values-{cluster.Id}.yaml"); - await File.WriteAllTextAsync(valuesPath, valuesYaml, ct); - - string installArgs = $"upgrade --install {releaseName} prometheus-community/kube-prometheus-stack " + - $"--namespace {targetNamespace} --create-namespace " + - $"--values {valuesPath} --wait --timeout 10m"; - - (bool success, string output) = await RunHelmAsync(installArgs, kubeconfigPath, cluster.ContextName, ct); - - // Clean up temp values file. - - if (File.Exists(valuesPath)) - { - File.Delete(valuesPath); - } - - if (!success) - { - logger.LogError("Helm install failed for cluster {ClusterId}: {Output}", cluster.Id, output); - return new HelmInstallOutput(false, output, null); - } - - string prometheusUrl = $"http://{releaseName}-prometheus.{targetNamespace}:9090"; - - logger.LogInformation( - "kube-prometheus-stack installed on cluster {ClusterId} in namespace {Namespace}", - cluster.Id, targetNamespace); - - return new HelmInstallOutput(true, null, prometheusUrl); - } - finally - { - // Clean up the temporary kubeconfig file. - - if (File.Exists(kubeconfigPath)) - { - File.Delete(kubeconfigPath); - } - } - } - - private async Task<(bool Success, string Output)> RunHelmAsync( - string arguments, - string kubeconfigPath, - string contextName, - CancellationToken ct) - { - ProcessStartInfo psi = new() - { - FileName = "helm", - Arguments = $"{arguments} --kubeconfig {kubeconfigPath} --kube-context {contextName}", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using Process? process = Process.Start(psi); - - if (process is null) - { - return (false, "Failed to start Helm process."); - } - - string stdout = await process.StandardOutput.ReadToEndAsync(ct); - string stderr = await process.StandardError.ReadToEndAsync(ct); - - await process.WaitForExitAsync(ct); - - bool success = process.ExitCode == 0; - string output = success ? stdout : stderr; - - return (success, output.Trim()); - } - - /// - /// Returns the enterprise production Helm values for kube-prometheus-stack. - /// This configures a highly available, resource-governed, secure monitoring stack. - /// - private static string GetProductionValues() - { - return """ - prometheus: - prometheusSpec: - replicas: 2 - retention: 30d - retentionSize: "45GB" - resources: - requests: - cpu: 500m - memory: 2Gi - limits: - cpu: "2" - memory: 8Gi - storageSpec: - volumeClaimTemplate: - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 50Gi - securityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 2000 - podAntiAffinity: hard - enableAdminAPI: false - service: - type: ClusterIP - - alertmanager: - alertmanagerSpec: - replicas: 3 - retention: 120h - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - storage: - volumeClaimTemplate: - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 10Gi - securityContext: - runAsNonRoot: true - runAsUser: 1000 - fsGroup: 2000 - - grafana: - enabled: true - replicas: 2 - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - persistence: - enabled: true - size: 10Gi - securityContext: - runAsNonRoot: true - runAsUser: 472 - fsGroup: 472 - - nodeExporter: - enabled: true - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 128Mi - - kubeStateMetrics: - enabled: true - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 256Mi - - prometheusOperator: - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - securityContext: - runAsNonRoot: true - - defaultRules: - create: true - rules: - alertmanager: true - etcd: true - configReloaders: true - general: true - k8sContainerCpuUsageSecondsTotal: true - k8sContainerMemoryCache: true - k8sPodOwner: true - kubeApiserver: true - kubeApiserverAvailability: true - kubeApiserverSlos: true - kubelet: true - kubeProxy: true - kubePrometheusGeneral: true - kubePrometheusNodeRecording: true - kubernetesApps: true - kubernetesResources: true - kubernetesStorage: true - kubernetesSystem: true - kubeSchedulerAlerting: true - kubeSchedulerRecording: true - kubeStateMetrics: true - network: true - node: true - nodeExporterAlerting: true - nodeExporterRecording: true - prometheus: true - prometheusOperator: true - """; - } -} diff --git a/src/EntKube.Clusters/Features/InstallPrometheus/IHelmPrometheusInstaller.cs b/src/EntKube.Clusters/Features/InstallPrometheus/IHelmPrometheusInstaller.cs deleted file mode 100644 index aac496e..0000000 --- a/src/EntKube.Clusters/Features/InstallPrometheus/IHelmPrometheusInstaller.cs +++ /dev/null @@ -1,24 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Features.InstallPrometheus; - -/// -/// Installs kube-prometheus-stack on a Kubernetes cluster via Helm. -/// Uses the cluster's kubeconfig and selected context to authenticate. -/// The implementation wraps Helm CLI or uses a Helm SDK to perform: -/// helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack -/// --namespace {namespace} --create-namespace --values (enterprise production config) -/// -public interface IHelmPrometheusInstaller -{ - Task InstallAsync( - KubernetesCluster cluster, - string releaseName, - string targetNamespace, - CancellationToken ct = default); -} - -/// -/// Result of a Helm install operation. -/// -public record HelmInstallOutput(bool Success, string? Error, string? PrometheusUrl); diff --git a/src/EntKube.Clusters/Features/InstallPrometheus/InstallPrometheusEndpoint.cs b/src/EntKube.Clusters/Features/InstallPrometheus/InstallPrometheusEndpoint.cs deleted file mode 100644 index 214acc7..0000000 --- a/src/EntKube.Clusters/Features/InstallPrometheus/InstallPrometheusEndpoint.cs +++ /dev/null @@ -1,37 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.InstallPrometheus; - -/// -/// Maps POST /api/clusters/{id}/install-prometheus — installs kube-prometheus-stack -/// via Helm on the target cluster. Used when a Prometheus scan returns empty and -/// the user opts to install a full monitoring stack. -/// -public static class InstallPrometheusEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters/{id:guid}/install-prometheus", async ( - Guid id, - [FromBody] InstallPrometheusBody? body, - [FromServices] InstallPrometheusHandler handler, - CancellationToken ct) => - { - string? targetNamespace = body?.Namespace; - - Result result = await handler.HandleAsync( - new InstallPrometheusRequest(id, targetNamespace), ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - } -} - -public record InstallPrometheusBody(string? Namespace); diff --git a/src/EntKube.Clusters/Features/InstallPrometheus/InstallPrometheusHandler.cs b/src/EntKube.Clusters/Features/InstallPrometheus/InstallPrometheusHandler.cs deleted file mode 100644 index c3cc0b6..0000000 --- a/src/EntKube.Clusters/Features/InstallPrometheus/InstallPrometheusHandler.cs +++ /dev/null @@ -1,94 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.InstallPrometheus; - -/// -/// Installs kube-prometheus-stack on a cluster when no Prometheus is found. -/// This handler delegates to the MonitoringInstaller (the same component installer -/// used by the adoption system) ensuring a single installation template everywhere. -/// -/// The handler: -/// 1. Finds the target cluster -/// 2. Delegates to the MonitoringInstaller via IComponentInstaller -/// 3. On success, stores the Prometheus endpoint on the cluster -/// 4. Returns the install result so the UI can confirm success -/// -/// Whether the user clicks "Install Prometheus" from registration or deploys -/// the "monitoring" component from the adoption report — the same Helm values -/// and configuration are used. -/// -public class InstallPrometheusHandler -{ - private readonly IClusterRepository repository; - private readonly IEnumerable installers; - - public InstallPrometheusHandler(IClusterRepository repository, IEnumerable installers) - { - this.repository = repository; - this.installers = installers; - } - - public async Task> HandleAsync(InstallPrometheusRequest request, CancellationToken ct = default) - { - // Find the cluster where we want to install Prometheus. - - KubernetesCluster? cluster = await repository.GetByIdAsync(request.ClusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{request.ClusterId}' was not found."); - } - - // Locate the monitoring component installer. This is the same installer - // that the adoption system uses, ensuring consistent configuration. - - IComponentInstaller? monitoringInstaller = installers - .FirstOrDefault(i => i.ComponentName == "monitoring"); - - if (monitoringInstaller is null) - { - return Result.Failure("Monitoring component installer not registered."); - } - - // Default to the "monitoring" namespace if none specified. - - string targetNamespace = string.IsNullOrWhiteSpace(request.Namespace) ? "monitoring" : request.Namespace; - string releaseName = "kube-prometheus-stack"; - - // Delegate to the MonitoringInstaller which applies the same production - // Helm values used throughout the platform (HA, storage, retention, Grafana). - - ComponentInstallOptions options = new( - Version: null, - Namespace: targetNamespace, - Parameters: null); - - InstallResult result = await monitoringInstaller.InstallAsync(cluster, options, ct); - - if (!result.Success) - { - return Result.Failure($"Monitoring install failed: {result.Message}"); - } - - // Store the new Prometheus endpoint on the cluster so metrics queries - // know where to find it. - - string prometheusUrl = $"http://{releaseName}-prometheus.{targetNamespace}:9090"; - - PrometheusEndpoint endpoint = new(prometheusUrl, targetNamespace, releaseName); - - List endpoints = cluster.PrometheusEndpoints.ToList(); - endpoints.Add(endpoint); - cluster.SetPrometheusEndpoints(endpoints); - - await repository.UpdateAsync(cluster, ct); - - return Result.Success(new PrometheusInstallResult(releaseName, targetNamespace, prometheusUrl)); - } -} - -public record InstallPrometheusRequest(Guid ClusterId, string? Namespace); - -public record PrometheusInstallResult(string ReleaseName, string Namespace, string PrometheusUrl); diff --git a/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterEndpoint.cs b/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterEndpoint.cs deleted file mode 100644 index 2905718..0000000 --- a/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.RegisterCluster; - -/// -/// Maps the HTTP POST /api/clusters endpoint. Receives a registration request, -/// delegates to the handler, and returns the new cluster's ID on success. -/// -public static class RegisterClusterEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters", async ( - [FromBody] RegisterClusterRequest request, - [FromServices] RegisterClusterHandler handler, - CancellationToken ct) => - { - EntKube.SharedKernel.Domain.Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created($"/api/clusters/{result.Value}", ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterHandler.cs b/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterHandler.cs deleted file mode 100644 index 5713f1b..0000000 --- a/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterHandler.cs +++ /dev/null @@ -1,76 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.RegisterCluster; - -/// -/// Handles the registration of a new Kubernetes cluster into the platform. -/// A tenant admin provides the cluster name, API server URL, and a kubeconfig. -/// Every cluster must be linked to a tenant — no orphan clusters are allowed. -/// We validate the input, create the cluster aggregate, and persist it. -/// The cluster starts in Pending state until the background health-check service -/// confirms connectivity. -/// -public class RegisterClusterHandler -{ - private readonly IClusterRepository repository; - - public RegisterClusterHandler(IClusterRepository repository) - { - this.repository = repository; - } - - public async Task> HandleAsync(RegisterClusterRequest request, CancellationToken ct = default) - { - // Validate that the caller provided the minimum required information. - - if (string.IsNullOrWhiteSpace(request.Name)) - { - return Result.Failure("Cluster name is required."); - } - - if (string.IsNullOrWhiteSpace(request.ApiServerUrl)) - { - return Result.Failure("API server URL is required."); - } - - if (string.IsNullOrWhiteSpace(request.KubeConfig)) - { - return Result.Failure("KubeConfig is required."); - } - - if (request.TenantId == Guid.Empty) - { - return Result.Failure("A tenant must be specified."); - } - - if (string.IsNullOrWhiteSpace(request.ContextName)) - { - return Result.Failure("A context name must be specified."); - } - - if (request.EnvironmentId == Guid.Empty) - { - return Result.Failure("An environment must be specified."); - } - - // Create the cluster aggregate using the domain factory method. - // This encapsulates all the business rules for what a valid new cluster looks like. - - KubernetesCluster cluster = KubernetesCluster.Register( - request.Name, - request.ApiServerUrl, - request.KubeConfig, - request.TenantId, - request.ContextName, - request.EnvironmentId); - - // Persist the new cluster so it can be picked up by the health-check background service. - - await repository.AddAsync(cluster, ct); - - return Result.Success(cluster.Id); - } -} - -public record RegisterClusterRequest(string Name, string ApiServerUrl, string KubeConfig, Guid TenantId, string ContextName, Guid EnvironmentId); diff --git a/src/EntKube.Clusters/Features/SetProvider/SetProviderEndpoint.cs b/src/EntKube.Clusters/Features/SetProvider/SetProviderEndpoint.cs deleted file mode 100644 index f0aa140..0000000 --- a/src/EntKube.Clusters/Features/SetProvider/SetProviderEndpoint.cs +++ /dev/null @@ -1,54 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.SetProvider; - -/// -/// Maps PUT /api/clusters/{id}/provider — assigns or clears the cloud provider -/// on an existing cluster. The body contains the provider name and credentials. -/// -public static class SetProviderEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPut("/api/clusters/{id:guid}/provider", async ( - Guid id, - [FromBody] SetProviderBody body, - [FromServices] SetProviderHandler handler, - CancellationToken ct) => - { - SetProviderRequest request = new( - id, - body.Provider, - body.Username, - body.Password, - body.Region, - body.OpenStackAuthUrl, - body.OpenStackProjectId, - body.OpenStackUsername, - body.OpenStackPassword, - body.OpenStackUserDomainName); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null)); - }); - } -} - -public record SetProviderBody( - string Provider, - string? Username, - string? Password, - string? Region, - string? OpenStackAuthUrl = null, - string? OpenStackProjectId = null, - string? OpenStackUsername = null, - string? OpenStackPassword = null, - string? OpenStackUserDomainName = null); diff --git a/src/EntKube.Clusters/Features/SetProvider/SetProviderHandler.cs b/src/EntKube.Clusters/Features/SetProvider/SetProviderHandler.cs deleted file mode 100644 index 36f2aca..0000000 --- a/src/EntKube.Clusters/Features/SetProvider/SetProviderHandler.cs +++ /dev/null @@ -1,74 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.SetProvider; - -/// -/// Handles assigning (or clearing) a cloud provider on an existing cluster. -/// When a provider is set, the credentials are stored so EntKube can call -/// the provider's APIs (e.g. Cleura REST API for OpenStack/Gardener). -/// -public class SetProviderHandler -{ - private readonly IClusterRepository repository; - - public SetProviderHandler(IClusterRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(SetProviderRequest request, CancellationToken ct = default) - { - // First, find the cluster. If it doesn't exist, there's nothing to configure. - - KubernetesCluster? cluster = await repository.GetByIdAsync(request.ClusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster {request.ClusterId} not found."); - } - - // Parse the provider name into the enum. If it's an unknown value, - // we reject it early rather than allowing invalid state. - - if (!Enum.TryParse(request.Provider, ignoreCase: true, out CloudProvider provider)) - { - return Result.Failure($"Unsupported provider: '{request.Provider}'. Supported: None, Cleura."); - } - - // Build the credentials (null for None provider, required for Cleura). - // Cleura has two APIs: the Cleura Cloud REST API (uses Username/Password) - // and the OpenStack API (uses separate OpenStack credentials via Keystone). - - ProviderCredentials? credentials = provider == CloudProvider.None - ? null - : new ProviderCredentials( - request.Username ?? "", - request.Password ?? "", - request.Region ?? "", - request.OpenStackAuthUrl, - request.OpenStackProjectId, - request.OpenStackUsername, - request.OpenStackPassword, - request.OpenStackUserDomainName); - - // Apply the change to the domain and persist. - - cluster.SetProvider(provider, credentials); - await repository.UpdateAsync(cluster, ct); - - return Result.Success(); - } -} - -public record SetProviderRequest( - Guid ClusterId, - string Provider, - string? Username, - string? Password, - string? Region, - string? OpenStackAuthUrl = null, - string? OpenStackProjectId = null, - string? OpenStackUsername = null, - string? OpenStackPassword = null, - string? OpenStackUserDomainName = null); diff --git a/src/EntKube.Clusters/Features/SetProvider/TestProviderEndpoint.cs b/src/EntKube.Clusters/Features/SetProvider/TestProviderEndpoint.cs deleted file mode 100644 index f7b91b3..0000000 --- a/src/EntKube.Clusters/Features/SetProvider/TestProviderEndpoint.cs +++ /dev/null @@ -1,188 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Infrastructure.Cleura; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.SetProvider; - -/// -/// Maps POST /api/clusters/{id}/provider/test — tests whether the stored provider -/// credentials can successfully authenticate against the provider's API. -/// -/// Cleura 2FA flow (four steps): -/// 1. POST with no body → login+password → "twofactor_options" → choose sms -/// 2. POST with no body → login+password+twofa_method="sms" → "twofactor_required" + verification -/// 3. Call /auth/v1/tokens/request2facode with verification → triggers SMS delivery -/// 4. POST with TwoFactorCode + Verification → call /auth/v1/tokens/verify2fa → "login_ok" -/// -/// Steps 1-3 happen in a single "Test Connection" click. -/// Step 4 happens when the user submits the SMS code. -/// -public static class TestProviderEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/clusters/{id:guid}/provider/test", async ( - Guid id, - [FromBody] TestProviderBody? body, - [FromServices] IClusterRepository repository, - [FromServices] CleuraClient cleuraClient, - CancellationToken ct) => - { - // Find the cluster and ensure it has a provider configured. - - KubernetesCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster {id} not found.")); - } - - if (cluster.Provider == CloudProvider.None || cluster.ProviderCredentials is null) - { - return Results.BadRequest(ApiResponse.Fail("No provider configured on this cluster.")); - } - - if (cluster.Provider == CloudProvider.Cleura) - { - // If the user submitted an SMS code + verification token, verify and complete login. - - if (!string.IsNullOrEmpty(body?.TwoFactorCode) && !string.IsNullOrEmpty(body?.Verification)) - { - if (!int.TryParse(body.TwoFactorCode.Trim(), out int code)) - { - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, "Invalid code format. Enter the numeric code from your SMS."))); - } - - // Step 4: Verify the SMS code and get a session token. - - Result verifyResult = await cleuraClient.Verify2faAsync( - cluster.ProviderCredentials.Username, - body.Verification, - code, - ct); - - if (verifyResult.IsFailure) - { - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, $"Code verification failed: {verifyResult.Error}"))); - } - - // Fetch regions to confirm full access. - - Result> domainsResult = await cleuraClient.GetDomainsAsync( - cluster.ProviderCredentials.Username, - verifyResult.Value!, - ct); - - List regions = domainsResult.IsSuccess - ? domainsResult.Value!.Select(d => d.Region).Distinct().ToList() - : new List(); - - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(true, "Authentication successful.", regions))); - } - - // Step 1: Try plain login to see what happens. - - Result initialResult = await cleuraClient.AuthenticateAsync( - cluster.ProviderCredentials.Username, - cluster.ProviderCredentials.Password, - null, - ct); - - if (initialResult.IsFailure) - { - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, initialResult.Error!))); - } - - CleuraAuthResponse authResponse = initialResult.Value!; - - // If login succeeded directly (no 2FA on this account). - - if (authResponse.Result == "login_ok") - { - Result> domainsResult = await cleuraClient.GetDomainsAsync( - cluster.ProviderCredentials.Username, - authResponse.Token, - ct); - - List regions = domainsResult.IsSuccess - ? domainsResult.Value!.Select(d => d.Region).Distinct().ToList() - : new List(); - - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(true, "Authentication successful.", regions))); - } - - // If 2FA options returned, we need to choose SMS and get a verification token. - - if (authResponse.Result == "twofactor_options") - { - // Step 2: Call again with twofa_method="sms" to select SMS. - - Result smsSelectResult = await cleuraClient.AuthenticateAsync( - cluster.ProviderCredentials.Username, - cluster.ProviderCredentials.Password, - "sms", - ct); - - if (smsSelectResult.IsFailure) - { - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, $"Failed to select SMS 2FA: {smsSelectResult.Error}"))); - } - - authResponse = smsSelectResult.Value!; - } - - // At this point we should have "twofactor_required" with a verification token. - - if (authResponse.Result == "twofactor_required" && !string.IsNullOrEmpty(authResponse.Verification)) - { - // Step 3: Request SMS delivery. - - Result smsRequestResult = await cleuraClient.Request2faCodeAsync( - cluster.ProviderCredentials.Username, - authResponse.Verification, - ct); - - if (smsRequestResult.IsFailure) - { - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, $"Failed to send SMS: {smsRequestResult.Error}"))); - } - - // Return the verification token so the UI can pass it back with the code. - - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, - "SMS code requested. Check your phone and enter the code below.", - Verification: authResponse.Verification))); - } - - // Unexpected response. - - return Results.Ok(ApiResponse.Ok( - new ProviderTestResult(false, $"Unexpected authentication result: {authResponse.Result}"))); - } - - return Results.BadRequest(ApiResponse.Fail($"Testing not supported for provider: {cluster.Provider}")); - }); - } -} - -public record ProviderTestResult( - bool Success, - string Message, - List? AvailableRegions = null, - string? Verification = null); - -/// -/// Body for the test endpoint. On first call, body is empty or null. -/// On second call (submitting SMS code), includes TwoFactorCode and Verification. -/// -public record TestProviderBody(string? TwoFactorCode = null, string? Verification = null); diff --git a/src/EntKube.Clusters/Features/UpdateClusterStatus/UpdateClusterStatusEndpoint.cs b/src/EntKube.Clusters/Features/UpdateClusterStatus/UpdateClusterStatusEndpoint.cs deleted file mode 100644 index 21aa356..0000000 --- a/src/EntKube.Clusters/Features/UpdateClusterStatus/UpdateClusterStatusEndpoint.cs +++ /dev/null @@ -1,39 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Clusters.Features.UpdateClusterStatus; - -/// -/// Maps PUT /api/clusters/{id}/status — updates a cluster's health status. -/// Typically called by the background health-check service, not directly by users. -/// -public static class UpdateClusterStatusEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPut("/api/clusters/{id:guid}/status", async ( - Guid id, - [FromBody] StatusUpdateRequest body, - [FromServices] UpdateClusterStatusHandler handler, - CancellationToken ct) => - { - if (!Enum.TryParse(body.Status, ignoreCase: true, out ClusterStatus newStatus)) - { - return Results.BadRequest(ApiResponse.Fail($"Invalid status '{body.Status}'. Valid values: Connected, Unreachable.")); - } - - Result result = await handler.HandleAsync(new UpdateClusterStatusRequest(id, newStatus), ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok("Status updated.")); - }); - } -} - -public record StatusUpdateRequest(string Status); diff --git a/src/EntKube.Clusters/Features/UpdateClusterStatus/UpdateClusterStatusHandler.cs b/src/EntKube.Clusters/Features/UpdateClusterStatus/UpdateClusterStatusHandler.cs deleted file mode 100644 index 45e8c52..0000000 --- a/src/EntKube.Clusters/Features/UpdateClusterStatus/UpdateClusterStatusHandler.cs +++ /dev/null @@ -1,54 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Features.UpdateClusterStatus; - -/// -/// Updates a cluster's health status. Called by the background health-check service -/// after probing the cluster's API server. If the probe succeeds, the cluster is -/// marked Connected; if it fails, Unreachable. This drives the UI status indicators -/// and determines whether new provisioning can be scheduled on the cluster. -/// -public class UpdateClusterStatusHandler -{ - private readonly IClusterRepository repository; - - public UpdateClusterStatusHandler(IClusterRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(UpdateClusterStatusRequest request, CancellationToken ct = default) - { - // Look up the cluster to update. - - KubernetesCluster? cluster = await repository.GetByIdAsync(request.ClusterId, ct); - - if (cluster is null) - { - return Result.Failure($"Cluster with ID '{request.ClusterId}' was not found."); - } - - // Apply the status transition based on the health check result. - - switch (request.NewStatus) - { - case ClusterStatus.Connected: - cluster.MarkConnected(); - break; - - case ClusterStatus.Unreachable: - cluster.MarkUnreachable(); - break; - - default: - return Result.Failure($"Cannot manually set status to '{request.NewStatus}'."); - } - - await repository.UpdateAsync(cluster, ct); - - return Result.Success(); - } -} - -public record UpdateClusterStatusRequest(Guid ClusterId, ClusterStatus NewStatus); diff --git a/src/EntKube.Clusters/Infrastructure/Cleura/CleuraClient.cs b/src/EntKube.Clusters/Infrastructure/Cleura/CleuraClient.cs deleted file mode 100644 index a1e2531..0000000 --- a/src/EntKube.Clusters/Infrastructure/Cleura/CleuraClient.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Infrastructure.Cleura; - -/// -/// Client for the Cleura Cloud REST API (https://rest.cleura.cloud). -/// Handles authentication and provides methods to query Cleura resources -/// like domains/regions. Uses session-based token authentication where -/// credentials produce a short-lived token for subsequent requests. -/// -public class CleuraClient -{ - private readonly HttpClient httpClient; - private static readonly JsonSerializerOptions jsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public CleuraClient(HttpClient httpClient) - { - this.httpClient = httpClient; - } - - /// - /// Authenticates with the Cleura REST API using username and password. - /// If twofa_method is specified, the API selects that 2FA method. - /// - /// Possible results: - /// - "login_ok" with token → success, no 2FA needed - /// - "twofactor_options" with options list → multiple 2FA methods, must choose - /// - "twofactor_required" with verification → 2FA chosen, need to request code - /// - /// Returns the full CleuraAuthResponse so callers can handle all cases. - /// - public async Task> AuthenticateAsync( - string username, - string password, - string? twofaMethod = null, - CancellationToken ct = default) - { - CleuraAuthRequest request = new(username, password, twofaMethod); - - HttpResponseMessage response = await httpClient.PostAsJsonAsync( - "/auth/v1/tokens", request, ct); - - if (!response.IsSuccessStatusCode) - { - return Result.Failure( - $"Cleura authentication failed with status {response.StatusCode}."); - } - - CleuraAuthResponse? authResponse = await response.Content - .ReadFromJsonAsync(jsonOptions, ct); - - if (authResponse is null) - { - return Result.Failure("Cleura authentication failed: empty response."); - } - - return Result.Success(authResponse); - } - - /// - /// Requests Cleura to send an SMS code to the user's phone. - /// Must be called after AuthenticateAsync returns "twofactor_required" with a verification token. - /// POST /auth/v1/tokens/request2facode - /// - public async Task Request2faCodeAsync( - string username, - string verification, - CancellationToken ct = default) - { - CleuraRequest2faCodeRequest request = new(username, verification); - - HttpResponseMessage response = await httpClient.PostAsJsonAsync( - "/auth/v1/tokens/request2facode", request, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Result.Failure($"Failed to request 2FA code: {response.StatusCode}. {body}"); - } - - return Result.Success(); - } - - /// - /// Verifies the SMS code the user received and completes login. - /// Returns the session token on success. - /// POST /auth/v1/tokens/verify2fa - /// - public async Task> Verify2faAsync( - string username, - string verification, - int code, - CancellationToken ct = default) - { - CleuraVerify2faRequest request = new(username, verification, code); - - HttpResponseMessage response = await httpClient.PostAsJsonAsync( - "/auth/v1/tokens/verify2fa", request, ct); - - if (!response.IsSuccessStatusCode) - { - return Result.Failure($"2FA verification failed with status {response.StatusCode}."); - } - - CleuraAuthResponse? authResponse = await response.Content - .ReadFromJsonAsync(jsonOptions, ct); - - if (authResponse is null) - { - return Result.Failure("2FA verification failed: empty response."); - } - - if (authResponse.Result != "login_ok") - { - return Result.Failure($"2FA verification failed: {authResponse.Result}"); - } - - return Result.Success(authResponse.Token); - } - - /// - /// Lists available OpenStack domains/regions for the authenticated user. - /// Requires a valid session token from a prior AuthenticateAsync call. - /// - public async Task>> GetDomainsAsync(string username, string token, CancellationToken ct = default) - { - using HttpRequestMessage request = new(HttpMethod.Get, "/accesscontrol/v1/openstack/domains"); - request.Headers.Add("X-AUTH-LOGIN", username); - request.Headers.Add("X-AUTH-TOKEN", token); - - HttpResponseMessage response = await httpClient.SendAsync(request, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - return Result.Failure>($"Failed to fetch Cleura domains: {response.StatusCode}. Body: {errorBody}"); - } - - string rawJson = await response.Content.ReadAsStringAsync(ct); - - try - { - CleuraDomainsResponse? domainsResponse = JsonSerializer.Deserialize(rawJson, jsonOptions); - - if (domainsResponse is null || domainsResponse.Domains.Count == 0) - { - return Result.Failure>($"Empty domains response. Raw: {rawJson[..Math.Min(500, rawJson.Length)]}"); - } - - return Result.Success(domainsResponse.Domains); - } - catch (JsonException) - { - // The API returned a different shape than expected — return raw for debugging - return Result.Failure>($"Cannot parse domains response. Raw: {rawJson[..Math.Min(500, rawJson.Length)]}"); - } - } -} diff --git a/src/EntKube.Clusters/Infrastructure/Cleura/CleuraModels.cs b/src/EntKube.Clusters/Infrastructure/Cleura/CleuraModels.cs deleted file mode 100644 index c6052b3..0000000 --- a/src/EntKube.Clusters/Infrastructure/Cleura/CleuraModels.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Text.Json.Serialization; - -namespace EntKube.Clusters.Infrastructure.Cleura; - -/// -/// DTOs for the Cleura Cloud REST API at https://rest.cleura.cloud. -/// These match the JSON shapes returned by the Cleura API exactly. -/// - -// --- Authentication --- - -/// -/// Request body for POST /auth/v1/tokens. -/// Cleura expects: {"auth": {"login": "...", "password": "...", "twofa_method": "sms"}} -/// -public class CleuraAuthRequest -{ - [JsonPropertyName("auth")] - public CleuraAuthBody Auth { get; set; } - - public CleuraAuthRequest(string login, string password, string? twofaMethod = null) - { - Auth = new CleuraAuthBody - { - Login = login, - Password = password, - TwofaMethod = twofaMethod - }; - } -} - -public class CleuraAuthBody -{ - [JsonPropertyName("login")] - public string Login { get; set; } = string.Empty; - - [JsonPropertyName("password")] - public string Password { get; set; } = string.Empty; - - [JsonPropertyName("twofa_method")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? TwofaMethod { get; set; } -} - -/// -/// Response from POST /auth/v1/tokens. -/// On success: {"result": "login_ok", "token": "..."} -/// On 2FA options: {"result": "twofactor_options", "options": ["sms", "webauthn"]} -/// On 2FA required: {"result": "twofactor_required", "verification": "xe9hik8q6r"} -/// -public class CleuraAuthResponse -{ - [JsonPropertyName("result")] - public string Result { get; set; } = string.Empty; - - [JsonPropertyName("token")] - public string Token { get; set; } = string.Empty; - - [JsonPropertyName("options")] - public List? Options { get; set; } - - [JsonPropertyName("verification")] - public string? Verification { get; set; } -} - -// --- Two Factor: Request SMS Code --- - -/// -/// Request body for POST /auth/v1/tokens/request2facode. -/// Triggers SMS delivery to the user's phone. -/// -public class CleuraRequest2faCodeRequest -{ - [JsonPropertyName("request2fa")] - public CleuraRequest2faBody Request2fa { get; set; } - - public CleuraRequest2faCodeRequest(string login, string verification) - { - Request2fa = new CleuraRequest2faBody - { - Login = login, - Verification = verification - }; - } -} - -public class CleuraRequest2faBody -{ - [JsonPropertyName("login")] - public string Login { get; set; } = string.Empty; - - [JsonPropertyName("verification")] - public string Verification { get; set; } = string.Empty; -} - -// --- Two Factor: Verify Code --- - -/// -/// Request body for POST /auth/v1/tokens/verify2fa. -/// Submits the SMS code to complete login. -/// -public class CleuraVerify2faRequest -{ - [JsonPropertyName("verify2fa")] - public CleuraVerify2faBody Verify2fa { get; set; } - - public CleuraVerify2faRequest(string login, string verification, int code) - { - Verify2fa = new CleuraVerify2faBody - { - Login = login, - Verification = verification, - Code = code - }; - } -} - -public class CleuraVerify2faBody -{ - [JsonPropertyName("login")] - public string Login { get; set; } = string.Empty; - - [JsonPropertyName("verification")] - public string Verification { get; set; } = string.Empty; - - [JsonPropertyName("code")] - public int Code { get; set; } -} - -// --- Domains / Regions --- - -/// -/// Response from GET /accesscontrol/v1/openstack/domains. -/// Contains a list of domain entries, each with a region. -/// -public class CleuraDomainsResponse -{ - [JsonPropertyName("domains")] - public List Domains { get; set; } = new(); -} - -public class CleuraDomain -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("region")] - public string Region { get; set; } = string.Empty; - - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; -} - - diff --git a/src/EntKube.Clusters/Infrastructure/Cleura/CleuraS3Client.cs b/src/EntKube.Clusters/Infrastructure/Cleura/CleuraS3Client.cs deleted file mode 100644 index 5e1a408..0000000 --- a/src/EntKube.Clusters/Infrastructure/Cleura/CleuraS3Client.cs +++ /dev/null @@ -1,389 +0,0 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Clusters.Infrastructure.Cleura; - -/// -/// Client for Cleura's S3-compatible object storage, which is backed by -/// OpenStack Radosgw (Ceph). This client handles: -/// 1. Authenticating via Keystone to get an auth token -/// 2. Creating/listing/deleting EC2 credentials (S3 access/secret keys) -/// 3. Providing the S3 endpoint URL for the region -/// -/// The S3 endpoint at s3-{region}.citycloud.com is a standard S3 API — -/// once credentials are created, any S3 client (AWSSDK, mc, rclone) can -/// connect using the access/secret key pair. -/// -/// This can replace MinIO for tenants using Cleura — no operator or pods needed, -/// just API calls to get credentials and an S3 endpoint. -/// -public class CleuraS3Client -{ - private readonly IHttpClientFactory httpClientFactory; - private static readonly JsonSerializerOptions jsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - public CleuraS3Client(IHttpClientFactory httpClientFactory) - { - this.httpClientFactory = httpClientFactory; - } - - /// - /// Creates an HttpClient pinned to HTTP/1.1. Cleura's Keystone API rejects - /// HTTP/2 requests with 411 Length Required because HTTP/2 doesn't send - /// Content-Length in the same way. - /// - private HttpClient CreateKeystoneClient() - { - HttpClient client = httpClientFactory.CreateClient(); - client.DefaultRequestVersion = System.Net.HttpVersion.Version11; - client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; - return client; - } - - /// - /// Returns the S3 endpoint URL for a given Cleura region. - /// All regions follow the pattern https://s3-{region}.citycloud.com (lowercase). - /// - public static string GetS3Endpoint(string region) - { - return $"https://s3-{region.ToLowerInvariant()}.citycloud.com"; - } - - /// - /// Authenticates against the OpenStack Keystone API to get a scoped token. - /// The token is returned in the X-Subject-Token response header, and the - /// body contains the user ID needed for EC2 credential operations. - /// - public async Task> AuthenticateKeystoneAsync( - string authUrl, - string username, - string password, - string userDomainName, - string projectId, - CancellationToken ct = default) - { - HttpClient client = CreateKeystoneClient(); - - // Build the Keystone auth request with project-scoped token. - // Cleura uses project name + domain name for scoping (matching the - // OpenStack RC file pattern with OS_PROJECT_NAME and OS_PROJECT_DOMAIN_NAME). - // If the value looks like a UUID, use project ID; otherwise treat it - // as a project name and scope with the user's domain. - - bool isUuid = Guid.TryParse(projectId.Replace("-", ""), out _) - || (projectId.Length == 32 && projectId.All(c => "0123456789abcdefABCDEF".Contains(c))); - - KeystoneProject scopeProject = isUuid - ? new KeystoneProject { Id = projectId } - : new KeystoneProject { Name = projectId, Domain = new KeystoneDomain { Name = userDomainName } }; - - KeystoneAuthRequest request = new() - { - Auth = new KeystoneAuth - { - Identity = new KeystoneIdentity - { - Methods = new List { "password" }, - Password = new KeystonePassword - { - User = new KeystoneUser - { - Name = username, - Password = password, - Domain = new KeystoneDomain { Name = userDomainName } - } - } - }, - Scope = new KeystoneScope - { - Project = scopeProject - } - } - }; - - string tokenUrl = authUrl.TrimEnd('/') + "/v3/auth/tokens"; - - string jsonBody = JsonSerializer.Serialize(request, jsonOptions); - - using HttpRequestMessage httpRequest = new(HttpMethod.Post, tokenUrl); - httpRequest.Version = System.Net.HttpVersion.Version11; - httpRequest.Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await client.SendAsync(httpRequest, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - return Result.Failure( - $"Keystone authentication failed: {response.StatusCode}. {errorBody}"); - } - - // The auth token is in the X-Subject-Token header. - - string? token = response.Headers.Contains("X-Subject-Token") - ? response.Headers.GetValues("X-Subject-Token").FirstOrDefault() - : null; - - if (string.IsNullOrEmpty(token)) - { - return Result.Failure("Keystone response missing X-Subject-Token header."); - } - - // Parse the body to get the user ID. - - KeystoneTokenResponse? tokenResponse = await response.Content - .ReadFromJsonAsync(jsonOptions, ct); - - if (tokenResponse is null) - { - return Result.Failure("Failed to parse Keystone token response."); - } - - // Use the project UUID from the token response — the user-provided - // projectId might be a name (e.g. "Default Project 43966") rather than - // a UUID. The token response always contains the resolved UUID. - - string resolvedProjectId = tokenResponse.Token.Project?.Id ?? projectId; - - return Result.Success(new KeystoneSession( - token, - tokenResponse.Token.User.Id, - tokenResponse.Token.User.Name, - resolvedProjectId, - authUrl)); - } - - /// - /// Creates a new set of EC2 credentials (S3 access/secret key pair). - /// These credentials are scoped to the project and can be used with - /// any S3-compatible client against the regional endpoint. - /// - public async Task> CreateEc2CredentialsAsync( - KeystoneSession session, - CancellationToken ct = default) - { - HttpClient client = CreateKeystoneClient(); - - string url = session.AuthUrl.TrimEnd('/') + - $"/v3/users/{session.UserId}/credentials/OS-EC2"; - - using HttpRequestMessage request = new(HttpMethod.Post, url); - request.Version = System.Net.HttpVersion.Version11; - request.Headers.Add("X-Auth-Token", session.Token); - - string jsonBody = JsonSerializer.Serialize(new Ec2CredentialCreateRequest - { - TenantId = session.ProjectId - }, jsonOptions); - request.Content = new StringContent(jsonBody, System.Text.Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await client.SendAsync(request, ct); - - if (!response.IsSuccessStatusCode) - { - return Result.Failure( - $"Failed to create EC2 credentials: {response.StatusCode}"); - } - - Ec2CredentialResponse? credResponse = await response.Content - .ReadFromJsonAsync(jsonOptions, ct); - - if (credResponse is null) - { - return Result.Failure("Failed to parse EC2 credential response."); - } - - return Result.Success(credResponse.Credential); - } - - /// - /// Lists existing EC2 credentials for the authenticated user. - /// - public async Task>> ListEc2CredentialsAsync( - KeystoneSession session, - CancellationToken ct = default) - { - HttpClient client = CreateKeystoneClient(); - - string url = session.AuthUrl.TrimEnd('/') + - $"/v3/users/{session.UserId}/credentials/OS-EC2"; - - using HttpRequestMessage request = new(HttpMethod.Get, url); - request.Version = System.Net.HttpVersion.Version11; - request.Headers.Add("X-Auth-Token", session.Token); - - HttpResponseMessage response = await client.SendAsync(request, ct); - - if (!response.IsSuccessStatusCode) - { - return Result.Failure>( - $"Failed to list EC2 credentials: {response.StatusCode}"); - } - - Ec2CredentialListResponse? listResponse = await response.Content - .ReadFromJsonAsync(jsonOptions, ct); - - if (listResponse is null) - { - return Result.Failure>("Failed to parse EC2 credentials list."); - } - - return Result.Success(listResponse.Credentials); - } - - /// - /// Deletes an EC2 credential by its access key ID. - /// - public async Task DeleteEc2CredentialsAsync( - KeystoneSession session, - string accessKeyId, - CancellationToken ct = default) - { - HttpClient client = CreateKeystoneClient(); - - string url = session.AuthUrl.TrimEnd('/') + - $"/v3/users/{session.UserId}/credentials/OS-EC2/{accessKeyId}"; - - using HttpRequestMessage request = new(HttpMethod.Delete, url); - request.Version = System.Net.HttpVersion.Version11; - request.Headers.Add("X-Auth-Token", session.Token); - - HttpResponseMessage response = await client.SendAsync(request, ct); - - if (!response.IsSuccessStatusCode) - { - return Result.Failure($"Failed to delete EC2 credential: {response.StatusCode}"); - } - - return Result.Success(); - } - - /// - /// Creates an S3 bucket on the Cleura S3-compatible endpoint using the - /// provided EC2 credentials. Optionally enables server-side encryption - /// (SSE-S3) so all objects stored in the bucket are encrypted at rest. - /// - public async Task CreateBucketAsync( - string endpoint, - string accessKey, - string secretKey, - string region, - string bucketName, - bool encrypted, - CancellationToken ct = default) - { - Amazon.S3.AmazonS3Client s3 = CreateS3Client(endpoint, accessKey, secretKey, region); - - try - { - Amazon.S3.Model.PutBucketRequest putRequest = new() - { - BucketName = bucketName, - UseClientRegion = true - }; - - await s3.PutBucketAsync(putRequest, ct); - - // NOTE: SSE-S3 default bucket encryption is NOT applied here. - // Cleura's RadosGW (Ceph) accepts the PutBucketEncryption API call, - // but then rejects all subsequent PutObject requests with HTTP 400 - // InvalidArgument (empty message). This is a known limitation of their - // S3-compatible storage — the encryption backend is not configured. - // The 'encrypted' parameter is accepted but not acted upon until - // Cleura adds proper SSE-S3 support. - - return Result.Success(); - } - catch (Amazon.S3.AmazonS3Exception ex) - { - return Result.Failure($"Failed to create bucket '{bucketName}': {ex.Message}"); - } - } - - /// - /// Lists all S3 buckets accessible with the provided credentials. - /// Returns just the bucket names for display purposes. - /// - public async Task>> ListBucketsAsync( - string endpoint, - string accessKey, - string secretKey, - string region, - CancellationToken ct = default) - { - Amazon.S3.AmazonS3Client s3 = CreateS3Client(endpoint, accessKey, secretKey, region); - - try - { - Amazon.S3.Model.ListBucketsResponse response = await s3.ListBucketsAsync(ct); - List bucketNames = response.Buckets.Select(b => b.BucketName).ToList(); - return Result.Success(bucketNames); - } - catch (Amazon.S3.AmazonS3Exception ex) - { - return Result.Failure>($"Failed to list buckets: {ex.Message}"); - } - } - - /// - /// Deletes an S3 bucket. The bucket must be empty for deletion to succeed. - /// - public async Task DeleteBucketAsync( - string endpoint, - string accessKey, - string secretKey, - string region, - string bucketName, - CancellationToken ct = default) - { - Amazon.S3.AmazonS3Client s3 = CreateS3Client(endpoint, accessKey, secretKey, region); - - try - { - await s3.DeleteBucketAsync(bucketName, ct); - return Result.Success(); - } - catch (Amazon.S3.AmazonS3Exception ex) - { - return Result.Failure($"Failed to delete bucket '{bucketName}': {ex.Message}"); - } - } - - /// - /// Creates an S3 client configured for the Cleura S3-compatible endpoint. - /// Uses path-style addressing since Cleura's endpoint doesn't support - /// virtual-hosted style bucket addressing. - /// - private static Amazon.S3.AmazonS3Client CreateS3Client( - string endpoint, - string accessKey, - string secretKey, - string region) - { - Amazon.S3.AmazonS3Config config = new() - { - ServiceURL = endpoint, - ForcePathStyle = true, - AuthenticationRegion = "us-east-1" - }; - - Amazon.Runtime.BasicAWSCredentials credentials = new(accessKey, secretKey); - return new Amazon.S3.AmazonS3Client(credentials, config); - } -} - -/// -/// Holds an authenticated Keystone session — the token and context needed -/// for subsequent API calls (EC2 credential management, etc.). -/// -public record KeystoneSession( - string Token, - string UserId, - string Username, - string ProjectId, - string AuthUrl); diff --git a/src/EntKube.Clusters/Infrastructure/Cleura/KeystoneModels.cs b/src/EntKube.Clusters/Infrastructure/Cleura/KeystoneModels.cs deleted file mode 100644 index f6e3701..0000000 --- a/src/EntKube.Clusters/Infrastructure/Cleura/KeystoneModels.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Text.Json.Serialization; - -namespace EntKube.Clusters.Infrastructure.Cleura; - -/// -/// DTOs for OpenStack Keystone identity API and EC2 credential management. -/// Used to authenticate and generate S3-compatible credentials for Cleura -/// object storage at s3-{region}.citycloud.com. -/// - -// --- Keystone Auth (POST /v3/auth/tokens) --- - -public class KeystoneAuthRequest -{ - [JsonPropertyName("auth")] - public KeystoneAuth Auth { get; set; } = new(); -} - -public class KeystoneAuth -{ - [JsonPropertyName("identity")] - public KeystoneIdentity Identity { get; set; } = new(); - - [JsonPropertyName("scope")] - public KeystoneScope? Scope { get; set; } -} - -public class KeystoneIdentity -{ - [JsonPropertyName("methods")] - public List Methods { get; set; } = new() { "password" }; - - [JsonPropertyName("password")] - public KeystonePassword Password { get; set; } = new(); -} - -public class KeystonePassword -{ - [JsonPropertyName("user")] - public KeystoneUser User { get; set; } = new(); -} - -public class KeystoneUser -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("password")] - public string Password { get; set; } = string.Empty; - - [JsonPropertyName("domain")] - public KeystoneDomain Domain { get; set; } = new(); -} - -public class KeystoneDomain -{ - [JsonPropertyName("name")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Name { get; set; } - - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } -} - -public class KeystoneScope -{ - [JsonPropertyName("project")] - public KeystoneProject? Project { get; set; } -} - -public class KeystoneProject -{ - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } - - [JsonPropertyName("name")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Name { get; set; } - - [JsonPropertyName("domain")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public KeystoneDomain? Domain { get; set; } -} - -// --- Keystone Token Response --- - -public class KeystoneTokenResponse -{ - [JsonPropertyName("token")] - public KeystoneTokenBody Token { get; set; } = new(); -} - -public class KeystoneTokenBody -{ - [JsonPropertyName("user")] - public KeystoneTokenUser User { get; set; } = new(); - - [JsonPropertyName("project")] - public KeystoneTokenProject? Project { get; set; } - - [JsonPropertyName("expires_at")] - public string? ExpiresAt { get; set; } -} - -public class KeystoneTokenProject -{ - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; - - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; -} - -public class KeystoneTokenUser -{ - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; - - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("domain")] - public KeystoneDomain Domain { get; set; } = new(); -} - -// --- EC2 Credentials (POST /v3/users/{user_id}/credentials/OS-EC2) --- - -public class Ec2CredentialCreateRequest -{ - [JsonPropertyName("tenant_id")] - public string TenantId { get; set; } = string.Empty; -} - -public class Ec2CredentialResponse -{ - [JsonPropertyName("credential")] - public Ec2Credential Credential { get; set; } = new(); -} - -public class Ec2CredentialListResponse -{ - [JsonPropertyName("credentials")] - public List Credentials { get; set; } = new(); -} - -public class Ec2Credential -{ - [JsonPropertyName("access")] - public string Access { get; set; } = string.Empty; - - [JsonPropertyName("secret")] - public string Secret { get; set; } = string.Empty; - - [JsonPropertyName("user_id")] - public string UserId { get; set; } = string.Empty; - - [JsonPropertyName("project_id")] - public string ProjectId { get; set; } = string.Empty; - - [JsonPropertyName("tenant_id")] - public string TenantId { get; set; } = string.Empty; -} diff --git a/src/EntKube.Clusters/Infrastructure/ClustersDbContext.cs b/src/EntKube.Clusters/Infrastructure/ClustersDbContext.cs deleted file mode 100644 index ae92743..0000000 --- a/src/EntKube.Clusters/Infrastructure/ClustersDbContext.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Text.Json; -using EntKube.Clusters.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Clusters.Infrastructure; - -/// -/// The EF Core database context for the Clusters service. Maps the KubernetesCluster -/// aggregate root with its owned collections: Components and StorageBuckets get their -/// own tables (they have independent identity), while PrometheusEndpoints and -/// ProviderCredentials are stored as JSON columns (value objects without identity). -/// -public class ClustersDbContext : DbContext -{ - public DbSet Clusters => Set(); - public DbSet Components => Set(); - public DbSet StorageBuckets => Set(); - - public ClustersDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity(entity => - { - entity.HasKey(c => c.Id); - entity.Property(c => c.TenantId); - entity.Property(c => c.EnvironmentId); - entity.Property(c => c.Name).IsRequired().HasMaxLength(200); - entity.Property(c => c.ApiServerUrl).IsRequired().HasMaxLength(500); - entity.Property(c => c.ContextName).HasMaxLength(200); - entity.Property(c => c.Status).HasConversion().HasMaxLength(50); - entity.Property(c => c.Provider).HasConversion().HasMaxLength(50); - entity.Property(c => c.KubeConfig); - entity.Property(c => c.RegisteredAt); - entity.Property(c => c.LastHealthCheckAt); - - // ProviderCredentials is a record — store as JSON column. - - entity.Property(c => c.ProviderCredentials) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - // PrometheusEndpoints are value objects — store as JSON column. - // Access the backing field directly. - - entity.Property>("prometheusEndpoints") - .HasField("prometheusEndpoints") - .HasColumnName("PrometheusEndpoints") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(c => c.PrometheusEndpoints); - - // Components get their own table — they have behavior and a ComponentName key. - - entity.HasMany("components") - .WithOne() - .HasForeignKey("ClusterId") - .OnDelete(DeleteBehavior.Cascade); - - entity.Ignore(c => c.Components); - - // StorageBuckets get their own table — they have a Guid Id. - - entity.HasMany("storageBuckets") - .WithOne() - .HasForeignKey("ClusterId") - .OnDelete(DeleteBehavior.Cascade); - - entity.Ignore(c => c.StorageBuckets); - }); - - modelBuilder.Entity(entity => - { - // ClusterComponent uses a shadow property for its PK and FK. - - entity.Property("Id").ValueGeneratedOnAdd(); - entity.HasKey("Id"); - entity.Property("ClusterId"); - entity.Property(c => c.ComponentName).IsRequired().HasMaxLength(200); - entity.Property(c => c.Status).HasConversion().HasMaxLength(50); - entity.Property(c => c.Version).HasMaxLength(100); - entity.Property(c => c.Namespace).HasMaxLength(200); - entity.Property(c => c.HelmReleaseName).HasMaxLength(200); - entity.Property(c => c.LastCheckedAt); - - // Configuration dictionary stored as JSON. - - entity.Property(c => c.Configuration) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(b => b.Id); - entity.Property("ClusterId"); - entity.Property(b => b.Name).IsRequired().HasMaxLength(200); - entity.Property(b => b.AccessKey).IsRequired().HasMaxLength(500); - entity.Property(b => b.SecretKey).IsRequired().HasMaxLength(500); - entity.Property(b => b.Endpoint).IsRequired().HasMaxLength(500); - entity.Property(b => b.Region).HasMaxLength(100); - entity.Property(b => b.Encrypted); - entity.Property(b => b.CreatedAt); - }); - } -} diff --git a/src/EntKube.Clusters/Infrastructure/EfClusterRepository.cs b/src/EntKube.Clusters/Infrastructure/EfClusterRepository.cs deleted file mode 100644 index d9a976b..0000000 --- a/src/EntKube.Clusters/Infrastructure/EfClusterRepository.cs +++ /dev/null @@ -1,85 +0,0 @@ -using EntKube.Clusters.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Clusters.Infrastructure; - -/// -/// EF Core implementation of the cluster repository. Eagerly loads the -/// Components and StorageBuckets collections since they are always needed -/// when working with a cluster aggregate. -/// -public class EfClusterRepository : IClusterRepository -{ - private readonly ClustersDbContext db; - - public EfClusterRepository(ClustersDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.Clusters - .Include("components") - .Include("storageBuckets") - .FirstOrDefaultAsync(c => c.Id == id, ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.Clusters - .Include("components") - .Include("storageBuckets") - .ToListAsync(ct); - } - - public async Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - db.Clusters.Add(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - // When a new StorageBucket is added to a tracked cluster's navigation, - // EF's DetectChanges auto-tracks it as Unchanged/Modified (not Added) - // because its Guid key is pre-set via Guid.NewGuid(). We must disable - // auto-detect to snapshot which buckets were loaded from the DB before - // EF discovers the new one, then explicitly Add any that are untracked. - - bool autoDetect = db.ChangeTracker.AutoDetectChangesEnabled; - db.ChangeTracker.AutoDetectChangesEnabled = false; - - try - { - HashSet trackedBucketIds = db.ChangeTracker.Entries() - .Select(e => e.Entity.Id) - .ToHashSet(); - - foreach (StorageBucket bucket in cluster.StorageBuckets) - { - if (!trackedBucketIds.Contains(bucket.Id)) - { - db.StorageBuckets.Add(bucket); - } - } - } - finally - { - db.ChangeTracker.AutoDetectChangesEnabled = autoDetect; - } - - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - KubernetesCluster? cluster = await db.Clusters.FindAsync([id], ct); - - if (cluster is not null) - { - db.Clusters.Remove(cluster); - await db.SaveChangesAsync(ct); - } - } -} diff --git a/src/EntKube.Clusters/Infrastructure/FileBackedClusterRepository.cs b/src/EntKube.Clusters/Infrastructure/FileBackedClusterRepository.cs deleted file mode 100644 index 90342d6..0000000 --- a/src/EntKube.Clusters/Infrastructure/FileBackedClusterRepository.cs +++ /dev/null @@ -1,315 +0,0 @@ -using System.Text.Json; -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; - -namespace EntKube.Clusters.Infrastructure; - -/// -/// A development-friendly cluster repository that keeps data in memory for fast -/// access but also persists to a JSON file on disk. This way data survives -/// service restarts during debugging. On startup, any previously saved clusters -/// are loaded from the file. On every write, the full state is flushed to disk. -/// -/// Production will replace this with an EF Core implementation backed by PostgreSQL. -/// -public class FileBackedClusterRepository : IClusterRepository -{ - private readonly List clusters = new(); - private readonly string filePath; - private readonly object lockObj = new(); - private static readonly JsonSerializerOptions jsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true - }; - - public FileBackedClusterRepository(IWebHostEnvironment env) - { - // Store the data file in the project's App_Data folder so it's easy to find - // and excluded from source control via .gitignore. - - string dataDir = Path.Combine(env.ContentRootPath, "App_Data"); - Directory.CreateDirectory(dataDir); - filePath = Path.Combine(dataDir, "clusters.json"); - - LoadFromDisk(); - } - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - lock (lockObj) - { - KubernetesCluster? cluster = clusters.FirstOrDefault(c => c.Id == id); - return Task.FromResult(cluster); - } - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - lock (lockObj) - { - IReadOnlyList result = clusters.ToList().AsReadOnly(); - return Task.FromResult(result); - } - } - - public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - lock (lockObj) - { - clusters.Add(cluster); - FlushToDisk(); - } - - return Task.CompletedTask; - } - - public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - lock (lockObj) - { - // The cluster reference is the same object, so the in-memory list - // is already updated. We just need to persist the new state. - - FlushToDisk(); - } - - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - lock (lockObj) - { - clusters.RemoveAll(c => c.Id == id); - FlushToDisk(); - } - - return Task.CompletedTask; - } - - // --- Persistence helpers --- - - private void LoadFromDisk() - { - if (!File.Exists(filePath)) - { - return; - } - - try - { - string json = File.ReadAllText(filePath); - List? dtos = JsonSerializer.Deserialize>(json, jsonOptions); - - if (dtos is null) - { - return; - } - - foreach (ClusterDto dto in dtos) - { - // Re-hydrate the domain object through the factory method, then - // override internal state from persisted data. - - KubernetesCluster cluster = KubernetesCluster.Register( - dto.Name, - dto.ApiServerUrl, - dto.KubeConfig, - dto.TenantId, - dto.ContextName, - dto.EnvironmentId); - - // Use reflection to restore the persisted ID and state. - // This is acceptable for a dev-only persistence layer. - - SetProperty(cluster, nameof(KubernetesCluster.Id), dto.Id); - SetProperty(cluster, nameof(KubernetesCluster.Status), dto.Status); - SetProperty(cluster, nameof(KubernetesCluster.RegisteredAt), dto.RegisteredAt); - SetProperty(cluster, nameof(KubernetesCluster.LastHealthCheckAt), dto.LastHealthCheckAt); - - if (dto.Provider != CloudProvider.None && dto.ProviderCredentials is not null) - { - cluster.SetProvider(dto.Provider, new ProviderCredentials( - dto.ProviderCredentials.Username, - dto.ProviderCredentials.Password, - dto.ProviderCredentials.Region, - dto.ProviderCredentials.OpenStackAuthUrl, - dto.ProviderCredentials.OpenStackProjectId, - dto.ProviderCredentials.OpenStackUsername, - dto.ProviderCredentials.OpenStackPassword, - dto.ProviderCredentials.OpenStackUserDomainName)); - } - - if (dto.PrometheusEndpoints is { Count: > 0 }) - { - cluster.SetPrometheusEndpoints(dto.PrometheusEndpoints); - } - - if (dto.Components is { Count: > 0 }) - { - // Re-hydrate persisted components via check results so the - // domain aggregate has its full component state restored. - - List checkResults = dto.Components - .Select(comp => new ComponentCheckResult( - comp.ComponentName, - comp.Status, - new List(), - new List(), - new DiscoveredConfiguration( - comp.Version, - comp.Namespace, - comp.HelmReleaseName, - comp.Configuration))) - .ToList(); - - cluster.UpdateComponents(checkResults); - } - - if (dto.StorageBuckets is { Count: > 0 }) - { - foreach (StorageBucketDto bucketDto in dto.StorageBuckets) - { - StorageBucket bucket = StorageBucket.Create( - bucketDto.Name, - bucketDto.AccessKey, - bucketDto.SecretKey, - bucketDto.Endpoint, - bucketDto.Region, - bucketDto.Encrypted); - - SetProperty(bucket, nameof(StorageBucket.Id), bucketDto.Id); - SetProperty(bucket, nameof(StorageBucket.CreatedAt), bucketDto.CreatedAt); - cluster.AddStorageBucket(bucket); - } - } - - clusters.Add(cluster); - } - } - catch (JsonException) - { - // If the file is corrupt, start fresh. - } - } - - private void FlushToDisk() - { - List dtos = clusters.Select(c => new ClusterDto - { - Id = c.Id, - TenantId = c.TenantId, - EnvironmentId = c.EnvironmentId, - Name = c.Name, - ApiServerUrl = c.ApiServerUrl, - ContextName = c.ContextName, - Status = c.Status, - Provider = c.Provider, - ProviderCredentials = c.ProviderCredentials is not null - ? new ProviderCredentialsDto - { - Username = c.ProviderCredentials.Username, - Password = c.ProviderCredentials.Password, - Region = c.ProviderCredentials.Region, - OpenStackAuthUrl = c.ProviderCredentials.OpenStackAuthUrl, - OpenStackProjectId = c.ProviderCredentials.OpenStackProjectId, - OpenStackUsername = c.ProviderCredentials.OpenStackUsername, - OpenStackPassword = c.ProviderCredentials.OpenStackPassword, - OpenStackUserDomainName = c.ProviderCredentials.OpenStackUserDomainName - } - : null, - KubeConfig = c.KubeConfig, - RegisteredAt = c.RegisteredAt, - LastHealthCheckAt = c.LastHealthCheckAt, - PrometheusEndpoints = c.PrometheusEndpoints.ToList(), - Components = c.Components.Select(comp => new ComponentDto - { - ComponentName = comp.ComponentName, - Status = comp.Status, - Version = comp.Version, - Namespace = comp.Namespace, - HelmReleaseName = comp.HelmReleaseName, - Configuration = new Dictionary(comp.Configuration), - LastCheckedAt = comp.LastCheckedAt - }).ToList(), - StorageBuckets = c.StorageBuckets.Select(b => new StorageBucketDto - { - Id = b.Id, - Name = b.Name, - AccessKey = b.AccessKey, - SecretKey = b.SecretKey, - Endpoint = b.Endpoint, - Region = b.Region, - Encrypted = b.Encrypted, - CreatedAt = b.CreatedAt - }).ToList() - }).ToList(); - - string json = JsonSerializer.Serialize(dtos, jsonOptions); - File.WriteAllText(filePath, json); - } - - private static void SetProperty(object obj, string propertyName, object? value) - { - System.Reflection.PropertyInfo? prop = obj.GetType().GetProperty(propertyName); - prop?.SetValue(obj, value); - } - - /// - /// DTO for JSON serialization — keeps the domain model free of persistence concerns. - /// - private class ClusterDto - { - public Guid Id { get; set; } - public Guid TenantId { get; set; } - public Guid EnvironmentId { get; set; } - public string Name { get; set; } = string.Empty; - public string ApiServerUrl { get; set; } = string.Empty; - public string ContextName { get; set; } = string.Empty; - public ClusterStatus Status { get; set; } - public CloudProvider Provider { get; set; } - public ProviderCredentialsDto? ProviderCredentials { get; set; } - public string KubeConfig { get; set; } = string.Empty; - public DateTimeOffset RegisteredAt { get; set; } - public DateTimeOffset? LastHealthCheckAt { get; set; } - public List PrometheusEndpoints { get; set; } = new(); - public List Components { get; set; } = new(); - public List StorageBuckets { get; set; } = new(); - } - - private class ProviderCredentialsDto - { - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - public string Region { get; set; } = string.Empty; - public string? OpenStackAuthUrl { get; set; } - public string? OpenStackProjectId { get; set; } - public string? OpenStackUsername { get; set; } - public string? OpenStackPassword { get; set; } - public string? OpenStackUserDomainName { get; set; } - } - - private class ComponentDto - { - public string ComponentName { get; set; } = string.Empty; - public ComponentStatus Status { get; set; } - public string? Version { get; set; } - public string? Namespace { get; set; } - public string? HelmReleaseName { get; set; } - public Dictionary Configuration { get; set; } = new(); - public DateTimeOffset LastCheckedAt { get; set; } - } - - private class StorageBucketDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string AccessKey { get; set; } = string.Empty; - public string SecretKey { get; set; } = string.Empty; - public string Endpoint { get; set; } = string.Empty; - public string Region { get; set; } = string.Empty; - public bool Encrypted { get; set; } - public DateTimeOffset CreatedAt { get; set; } - } -} diff --git a/src/EntKube.Clusters/Infrastructure/InMemoryClusterRepository.cs b/src/EntKube.Clusters/Infrastructure/InMemoryClusterRepository.cs deleted file mode 100644 index a8c699a..0000000 --- a/src/EntKube.Clusters/Infrastructure/InMemoryClusterRepository.cs +++ /dev/null @@ -1,43 +0,0 @@ -using EntKube.Clusters.Domain; - -namespace EntKube.Clusters.Infrastructure; - -/// -/// In-memory implementation of the cluster repository for local development -/// and testing. Production will replace this with an EF Core or Dapper -/// implementation backed by PostgreSQL. -/// -public class InMemoryClusterRepository : IClusterRepository -{ - private readonly List clusters = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - KubernetesCluster? cluster = clusters.FirstOrDefault(c => c.Id == id); - return Task.FromResult(cluster); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = clusters.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - clusters.Add(cluster); - return Task.CompletedTask; - } - - public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - // In-memory: the reference is already updated since we store the object directly. - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - clusters.RemoveAll(c => c.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Clusters/Program.cs b/src/EntKube.Clusters/Program.cs deleted file mode 100644 index 47450cc..0000000 --- a/src/EntKube.Clusters/Program.cs +++ /dev/null @@ -1,286 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.Components.CertManager; -using EntKube.Clusters.Features.AdoptCluster.Components.CloudNativePG; -using EntKube.Clusters.Features.AdoptCluster.Components.CustomDNS; -using EntKube.Clusters.Features.AdoptCluster.Components.DomainCA; -using EntKube.Clusters.Features.AdoptCluster.Components.ExternalSecrets; -using EntKube.Clusters.Features.AdoptCluster.Components.Gitea; -using EntKube.Clusters.Features.AdoptCluster.Components.Grafana; -using EntKube.Clusters.Features.AdoptCluster.Components.Harbor; -using EntKube.Clusters.Features.AdoptCluster.Components.Ingress; -using EntKube.Clusters.Features.AdoptCluster.Components.InternalCA; -using EntKube.Clusters.Features.AdoptCluster.Components.Istio; -using EntKube.Clusters.Features.AdoptCluster.Components.Keycloak; -using EntKube.Clusters.Features.AdoptCluster.Components.Kyverno; -using EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt; -using EntKube.Clusters.Features.AdoptCluster.Components.MinIO; -using EntKube.Clusters.Features.AdoptCluster.Components.MongoDBCommunity; -using EntKube.Clusters.Features.AdoptCluster.Components.Monitoring; -using EntKube.Clusters.Features.AdoptCluster.Components.NetworkPolicies; -using EntKube.Clusters.Features.AdoptCluster.Components.RabbitMQ; -using EntKube.Clusters.Features.AdoptCluster.Components.Redis; -using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies; -using EntKube.Clusters.Features.AdoptCluster.Components.Traefik; -using EntKube.Clusters.Features.AdoptCluster.Components.TrustManager; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Features.AdoptCluster.GetComponentSchemas; -using EntKube.Clusters.Features.AdoptCluster.UninstallComponent; -using EntKube.Clusters.Features.AdoptCluster.UpgradeComponent; -using EntKube.Clusters.Features.ClusterHealth; -using EntKube.Clusters.Features.DeleteCluster; -using EntKube.Clusters.Features.DiscoverPrometheus; -using EntKube.Clusters.Features.GetClusterById; -using EntKube.Clusters.Features.GetClusters; -using EntKube.Clusters.Features.InstallPrometheus; -using EntKube.Clusters.Features.RegisterCluster; -using EntKube.Clusters.Features.CheckConnectivity; -using EntKube.Clusters.Features.SetProvider; -using EntKube.Clusters.Features.CleuraStorage; -using EntKube.Clusters.Features.Certificates; -using EntKube.Clusters.Features.ClusterSettings; -using EntKube.Clusters.Features.UpdateClusterStatus; -using EntKube.Clusters.Infrastructure; -using EntKube.Clusters.Infrastructure.Cleura; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Clusters; - -public class Program -{ - public static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - // Register the database context. The provider is selected based on - // the DatabaseProvider config value: Sqlite (default), Postgres, or SqlServer. - - string provider = builder.Configuration["DatabaseProvider"] ?? "Sqlite"; - string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=App_Data/clusters.db"; - - builder.Services.AddDbContext(options => - { - switch (provider.ToLowerInvariant()) - { - case "postgres": - options.UseNpgsql(connectionString); - break; - - case "sqlserver": - options.UseSqlServer(connectionString); - break; - - default: - options.UseSqlite(connectionString); - break; - } - }); - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Cleura Cloud REST API client — used for provider integration. - // Base address is the Cleura API gateway at rest.cleura.cloud. - // Force HTTP/1.1 because Cleura's API drops HTTP/2 connections prematurely. - - builder.Services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://rest.cleura.cloud"); - client.DefaultRequestVersion = System.Net.HttpVersion.Version11; - client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; - }); - - // Secrets service client — used by component installers (Harbor, etc.) - // to store generated credentials (database passwords) in the vault. - - builder.Services.AddHttpClient("SecretsApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Secrets:BaseUrl"] ?? "https://localhost:5040"); - }); - - builder.Services.AddSingleton(); - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Adoption checks — each component registers as an IAdoptionCheck. - // The coordinator runs all of them to produce an aggregate report. - // Sorted alphabetically by component name for consistency. - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - - // Component installers — each component that can be deployed on-demand. - // Sorted alphabetically by component name for consistency. - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddHealthChecks(); - builder.Services.AddOpenApi(); - - WebApplication app = builder.Build(); - - // Apply pending database migrations on startup. - - MigrateDatabase(app); - - // Configure the HTTP pipeline with OpenAPI for development tooling. - - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - } - - app.UseHttpsRedirection(); - - // Map feature endpoints — each feature registers its own routes. - - RegisterClusterEndpoint.Map(app); - GetClustersEndpoint.Map(app); - GetClusterByIdEndpoint.Map(app); - GetClusterCredentialsEndpoint.Map(app); - GetClusterGatewayEndpoint.Map(app); - DeleteClusterEndpoint.Map(app); - UpdateClusterStatusEndpoint.Map(app); - CheckConnectivityEndpoint.Map(app); - DiscoverPrometheusEndpoint.Map(app); - InstallPrometheusEndpoint.Map(app); - ClusterHealthEndpoint.Map(app); - AdoptClusterEndpoint.Map(app); - DeployComponentEndpoint.Map(app); - UpgradeComponentEndpoint.Map(app); - GetAvailableVersionsEndpoint.Map(app); - UninstallComponentEndpoint.Map(app); - ConfigureComponentEndpoint.Map(app); - GetComponentSchemasEndpoint.Map(app); - SetProviderEndpoint.Map(app); - TestProviderEndpoint.Map(app); - CleuraStorageEndpoints.Map(app); - StorageBucketEndpoints.Map(app); - CertificateEndpoints.Map(app); - ClusterSettingsEndpoint.Map(app); - HarborEndpoints.Map(app); -GiteaEndpoints.Map(app); - - app.MapHealthChecks("/health"); - app.MapHealthChecks("/health/ready"); - - app.Run(); - } - - private static void MigrateDatabase(WebApplication app) - { - using IServiceScope scope = app.Services.CreateScope(); - ClustersDbContext db = scope.ServiceProvider.GetRequiredService(); - ILogger logger = scope.ServiceProvider.GetRequiredService>(); - - // SQLite needs the directory to exist before it can create the database file. - - string? connectionString = db.Database.GetConnectionString(); - - if (connectionString != null) - { - Microsoft.Data.Sqlite.SqliteConnectionStringBuilder csb = new(connectionString); - - if (!string.IsNullOrEmpty(csb.DataSource) && csb.DataSource != ":memory:") - { - string? directory = Path.GetDirectoryName(Path.GetFullPath(csb.DataSource)); - - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - } - } - - int maxRetries = 5; - int delayMs = 1000; - - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - db.Database.EnsureCreated(); - logger.LogInformation("Database ready on attempt {Attempt}.", attempt); - return; - } - catch (Exception ex) when (attempt < maxRetries) - { - logger.LogWarning( - ex, - "Database setup attempt {Attempt}/{MaxRetries} failed. Retrying in {DelayMs}ms...", - attempt, - maxRetries, - delayMs); - - Thread.Sleep(delayMs); - delayMs *= 2; - } - } - } -} diff --git a/src/EntKube.Clusters/Properties/launchSettings.json b/src/EntKube.Clusters/Properties/launchSettings.json deleted file mode 100644 index 6ad7b86..0000000 --- a/src/EntKube.Clusters/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5010", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7010;http://localhost:5010", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/EntKube.Clusters/appsettings.Development.json b/src/EntKube.Clusters/appsettings.Development.json deleted file mode 100644 index 2d6cd3d..0000000 --- a/src/EntKube.Clusters/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/EntKube.Clusters/appsettings.json b/src/EntKube.Clusters/appsettings.json deleted file mode 100644 index d0111f5..0000000 --- a/src/EntKube.Clusters/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/EntKube.Identity/Dockerfile b/src/EntKube.Identity/Dockerfile deleted file mode 100644 index ac1570c..0000000 --- a/src/EntKube.Identity/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 5030 - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["src/EntKube.Identity/EntKube.Identity.csproj", "src/EntKube.Identity/"] -COPY ["src/EntKube.SharedKernel/EntKube.SharedKernel.csproj", "src/EntKube.SharedKernel/"] -RUN dotnet restore "src/EntKube.Identity/EntKube.Identity.csproj" -COPY . . -WORKDIR "/src/src/EntKube.Identity" -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://+:5030 -ENTRYPOINT ["dotnet", "EntKube.Identity.dll"] diff --git a/src/EntKube.Identity/Domain/Customer.cs b/src/EntKube.Identity/Domain/Customer.cs deleted file mode 100644 index 318db58..0000000 --- a/src/EntKube.Identity/Domain/Customer.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace EntKube.Identity.Domain; - -/// -/// A Customer represents a team, department, or external organization within a tenant. -/// In the terraform reference these map to "tenants" like capioDA, capioonline, volvat — -/// each getting their own Kubernetes namespaces, resource quotas, ArgoCD repos, -/// RBAC groups, and app deployments per environment. -/// -/// Customers are the unit of isolation within a tenant: each customer's workloads -/// run in dedicated namespaces, their secrets are scoped separately, and their -/// resource consumption is tracked independently for cost allocation. -/// -public class Customer -{ - public Guid Id { get; private set; } - public Guid TenantId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Slug { get; private set; } = string.Empty; - public CustomerStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - - private Customer() { } - - /// - /// Onboards a new customer within a tenant. Think of this as an admin - /// registering a new team — "Capio Data Analytics", "Capio Online", "Volvat" — - /// each of which will get per-environment namespaces and resource allocations. - /// - public static Customer Create(Guid tenantId, string name, string slug) - { - // Every customer must belong to a tenant — it doesn't exist in isolation. - - if (tenantId == Guid.Empty) - { - throw new ArgumentException("Tenant ID is required.", nameof(tenantId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Customer name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Customer slug is required.", nameof(slug)); - } - - return new Customer - { - Id = Guid.NewGuid(), - TenantId = tenantId, - Name = name, - Slug = slug.ToLowerInvariant(), - Status = CustomerStatus.Active, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - /// - /// Suspends the customer. Their workloads and namespaces remain but - /// new deployments and provisioning requests are blocked. - /// - public void Suspend() - { - Status = CustomerStatus.Suspended; - } - - /// - /// Re-activates a previously suspended customer. - /// - public void Activate() - { - Status = CustomerStatus.Active; - } -} - -public enum CustomerStatus -{ - Active, - Suspended -} diff --git a/src/EntKube.Identity/Domain/ICustomerRepository.cs b/src/EntKube.Identity/Domain/ICustomerRepository.cs deleted file mode 100644 index 999c62a..0000000 --- a/src/EntKube.Identity/Domain/ICustomerRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace EntKube.Identity.Domain; - -/// -/// Defines how the identity service persists and retrieves customers. -/// -public interface ICustomerRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByTenantIdAsync(Guid tenantId, CancellationToken ct = default); - Task GetByTenantAndSlugAsync(Guid tenantId, string slug, CancellationToken ct = default); - Task AddAsync(Customer customer, CancellationToken ct = default); - Task UpdateAsync(Customer customer, CancellationToken ct = default); -} diff --git a/src/EntKube.Identity/Domain/IEnvironmentRepository.cs b/src/EntKube.Identity/Domain/IEnvironmentRepository.cs deleted file mode 100644 index 7f920ac..0000000 --- a/src/EntKube.Identity/Domain/IEnvironmentRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace EntKube.Identity.Domain; - -/// -/// Defines how the identity service persists and retrieves environments. -/// -public interface IEnvironmentRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByTenantIdAsync(Guid tenantId, CancellationToken ct = default); - Task GetByTenantAndSlugAsync(Guid tenantId, string slug, CancellationToken ct = default); - Task AddAsync(TenantEnvironment environment, CancellationToken ct = default); - Task UpdateAsync(TenantEnvironment environment, CancellationToken ct = default); -} diff --git a/src/EntKube.Identity/Domain/ITenantRepository.cs b/src/EntKube.Identity/Domain/ITenantRepository.cs deleted file mode 100644 index 1a65cab..0000000 --- a/src/EntKube.Identity/Domain/ITenantRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace EntKube.Identity.Domain; - -/// -/// Defines how the identity service persists and retrieves tenants. -/// -public interface ITenantRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetBySlugAsync(string slug, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(Tenant tenant, CancellationToken ct = default); - Task UpdateAsync(Tenant tenant, CancellationToken ct = default); -} diff --git a/src/EntKube.Identity/Domain/Tenant.cs b/src/EntKube.Identity/Domain/Tenant.cs deleted file mode 100644 index 26c5476..0000000 --- a/src/EntKube.Identity/Domain/Tenant.cs +++ /dev/null @@ -1,91 +0,0 @@ -namespace EntKube.Identity.Domain; - -/// -/// A Tenant represents an organization or team using the EntKube platform. -/// Each tenant has isolated access to clusters and provisioned services. -/// The Identity service owns tenant lifecycle (creation, suspension, deletion) -/// and user membership within tenants. -/// -public class Tenant -{ - public Guid Id { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Slug { get; private set; } = string.Empty; - public TenantStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - - private readonly List members = new(); - public IReadOnlyList Members => members.AsReadOnly(); - - private Tenant() { } - - /// - /// Creates a new tenant in the platform. The creating user automatically - /// becomes the tenant's first admin member. - /// - public static Tenant Create(string name, string slug, Guid creatingUserId) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Tenant name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Tenant slug is required.", nameof(slug)); - } - - Tenant tenant = new() - { - Id = Guid.NewGuid(), - Name = name, - Slug = slug.ToLowerInvariant(), - Status = TenantStatus.Active, - CreatedAt = DateTimeOffset.UtcNow - }; - - // The creator is automatically the admin of their new tenant. - - tenant.members.Add(new TenantMember(creatingUserId, TenantRole.Admin)); - - return tenant; - } - - /// - /// Adds a user to this tenant with the specified role. - /// - public void AddMember(Guid userId, TenantRole role) - { - if (members.Any(m => m.UserId == userId)) - { - return; - } - - members.Add(new TenantMember(userId, role)); - } - - public void Suspend() - { - Status = TenantStatus.Suspended; - } - - public void Activate() - { - Status = TenantStatus.Active; - } -} - -public record TenantMember(Guid UserId, TenantRole Role); - -public enum TenantStatus -{ - Active, - Suspended -} - -public enum TenantRole -{ - Admin, - Member, - Viewer -} diff --git a/src/EntKube.Identity/Domain/TenantEnvironment.cs b/src/EntKube.Identity/Domain/TenantEnvironment.cs deleted file mode 100644 index e6aac5a..0000000 --- a/src/EntKube.Identity/Domain/TenantEnvironment.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace EntKube.Identity.Domain; - -/// -/// A TenantEnvironment represents a deployment stage within a tenant's organization -/// — for example "dev", "test", or "prod". Each environment can have one or many -/// Kubernetes clusters assigned to it, and platform services (MinIO, CNPG, Keycloak, etc.) -/// are provisioned per-environment with a home cluster and optional multi-cluster stretching. -/// -/// The environment is the primary organizational boundary for infrastructure: -/// clusters belong to environments, services belong to environments, and -/// customers get per-environment configuration (quotas, repos, RBAC groups). -/// -public class TenantEnvironment -{ - public Guid Id { get; private set; } - public Guid TenantId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Slug { get; private set; } = string.Empty; - public EnvironmentStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - - private TenantEnvironment() { } - - /// - /// Creates a new environment within a tenant. Think of this as an admin - /// setting up a new stage in their deployment pipeline — "dev", "staging", - /// "prod", etc. The environment starts active and ready to receive clusters. - /// - public static TenantEnvironment Create(Guid tenantId, string name, string slug) - { - // Every environment must belong to a tenant — it doesn't exist in isolation. - - if (tenantId == Guid.Empty) - { - throw new ArgumentException("Tenant ID is required.", nameof(tenantId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Environment name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("Environment slug is required.", nameof(slug)); - } - - return new TenantEnvironment - { - Id = Guid.NewGuid(), - TenantId = tenantId, - Name = name, - Slug = slug.ToLowerInvariant(), - Status = EnvironmentStatus.Active, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - /// - /// Deactivates the environment. No new services or clusters should be - /// assigned to an inactive environment. Existing resources remain but - /// are effectively frozen. - /// - public void Deactivate() - { - Status = EnvironmentStatus.Inactive; - } - - /// - /// Re-activates a previously deactivated environment. - /// - public void Activate() - { - Status = EnvironmentStatus.Active; - } -} - -public enum EnvironmentStatus -{ - Active, - Inactive -} diff --git a/src/EntKube.Identity/EntKube.Identity.csproj b/src/EntKube.Identity/EntKube.Identity.csproj deleted file mode 100644 index 932886c..0000000 --- a/src/EntKube.Identity/EntKube.Identity.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - diff --git a/src/EntKube.Identity/EntKube.Identity.http b/src/EntKube.Identity/EntKube.Identity.http deleted file mode 100644 index 796a773..0000000 --- a/src/EntKube.Identity/EntKube.Identity.http +++ /dev/null @@ -1,6 +0,0 @@ -@EntKube.Identity_HostAddress = http://localhost:5076 - -GET {{EntKube.Identity_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/EntKube.Identity/Features/CreateTenant/CreateTenantEndpoint.cs b/src/EntKube.Identity/Features/CreateTenant/CreateTenantEndpoint.cs deleted file mode 100644 index d236767..0000000 --- a/src/EntKube.Identity/Features/CreateTenant/CreateTenantEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Identity.Features.CreateTenant; - -/// -/// Maps POST /api/tenants — creates a new tenant organization. -/// -public static class CreateTenantEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/tenants", async ( - [FromBody] CreateTenantRequest request, - [FromServices] CreateTenantHandler handler, - CancellationToken ct) => - { - EntKube.SharedKernel.Domain.Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created($"/api/tenants/{result.Value}", ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Identity/Features/CreateTenant/CreateTenantHandler.cs b/src/EntKube.Identity/Features/CreateTenant/CreateTenantHandler.cs deleted file mode 100644 index a8235e9..0000000 --- a/src/EntKube.Identity/Features/CreateTenant/CreateTenantHandler.cs +++ /dev/null @@ -1,59 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Identity.Features.CreateTenant; - -/// -/// Handles creation of a new tenant. When a user signs up or an admin creates -/// a new organization, this handler validates the request, ensures the slug is -/// unique, creates the tenant aggregate, and persists it. The creating user -/// becomes the first admin of the tenant. -/// -public class CreateTenantHandler -{ - private readonly ITenantRepository repository; - - public CreateTenantHandler(ITenantRepository repository) - { - this.repository = repository; - } - - public async Task> HandleAsync(CreateTenantRequest request, CancellationToken ct = default) - { - // Validate required fields. - - if (string.IsNullOrWhiteSpace(request.Name)) - { - return Result.Failure("Tenant name is required."); - } - - if (string.IsNullOrWhiteSpace(request.Slug)) - { - return Result.Failure("Tenant slug is required."); - } - - if (request.CreatingUserId == Guid.Empty) - { - return Result.Failure("Creating user ID is required."); - } - - // Ensure no other tenant already uses this slug. Slugs are used in URLs - // and must be globally unique across the platform. - - Tenant? existing = await repository.GetBySlugAsync(request.Slug.ToLowerInvariant(), ct); - - if (existing is not null) - { - return Result.Failure($"A tenant with slug '{request.Slug}' already exists."); - } - - // Create the tenant aggregate and persist it. - - Tenant tenant = Tenant.Create(request.Name, request.Slug, request.CreatingUserId); - await repository.AddAsync(tenant, ct); - - return Result.Success(tenant.Id); - } -} - -public record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId); diff --git a/src/EntKube.Identity/Features/Customers/CustomerEndpoints.cs b/src/EntKube.Identity/Features/Customers/CustomerEndpoints.cs deleted file mode 100644 index 70d458a..0000000 --- a/src/EntKube.Identity/Features/Customers/CustomerEndpoints.cs +++ /dev/null @@ -1,79 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Identity.Features.Customers; - -/// -/// API endpoints for managing tenant customers (teams/organizations within a tenant). -/// Customers map to the "tenants" in the terraform reference — each is a team -/// like capioDA, capioonline, volvat that gets its own namespaces and quotas. -/// -public static class CustomerEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - // GET /api/tenants/{tenantId}/customers — list all customers for a tenant. - - app.MapGet("/api/tenants/{tenantId:guid}/customers", async ( - Guid tenantId, - [FromServices] ICustomerRepository repository, - CancellationToken ct) => - { - IReadOnlyList customers = await repository.GetByTenantIdAsync(tenantId, ct); - - List summaries = customers.Select(c => new CustomerSummary( - c.Id, - c.TenantId, - c.Name, - c.Slug, - c.Status.ToString(), - c.CreatedAt)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - - // POST /api/tenants/{tenantId}/customers — onboard a new customer. - - app.MapPost("/api/tenants/{tenantId:guid}/customers", async ( - Guid tenantId, - [FromBody] CreateCustomerRequest request, - [FromServices] ICustomerRepository repository, - [FromServices] ITenantRepository tenantRepository, - CancellationToken ct) => - { - // Make sure the tenant exists. - - Tenant? tenant = await tenantRepository.GetByIdAsync(tenantId, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("Tenant not found.")); - } - - // Check slug uniqueness within the tenant. - - Customer? existing = await repository.GetByTenantAndSlugAsync(tenantId, request.Slug, ct); - - if (existing is not null) - { - return Results.Conflict(ApiResponse.Fail($"A customer with slug '{request.Slug}' already exists.")); - } - - Customer customer = Customer.Create(tenantId, request.Name, request.Slug); - await repository.AddAsync(customer, ct); - - return Results.Ok(ApiResponse.Ok(customer.Id)); - }); - } -} - -public record CustomerSummary( - Guid Id, - Guid TenantId, - string Name, - string Slug, - string Status, - DateTimeOffset CreatedAt); - -public record CreateCustomerRequest(string Name, string Slug); diff --git a/src/EntKube.Identity/Features/Environments/EnvironmentEndpoints.cs b/src/EntKube.Identity/Features/Environments/EnvironmentEndpoints.cs deleted file mode 100644 index 630409d..0000000 --- a/src/EntKube.Identity/Features/Environments/EnvironmentEndpoints.cs +++ /dev/null @@ -1,81 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Identity.Features.Environments; - -/// -/// API endpoints for managing tenant environments (dev, test, prod, etc.). -/// Environments are the deployment stages within a tenant — clusters are -/// assigned to environments, and services are provisioned per-environment. -/// -public static class EnvironmentEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - // GET /api/tenants/{tenantId}/environments — list all environments for a tenant. - // The BFF calls this when the user opens the Environments tab on the tenant detail page. - - app.MapGet("/api/tenants/{tenantId:guid}/environments", async ( - Guid tenantId, - [FromServices] IEnvironmentRepository repository, - CancellationToken ct) => - { - IReadOnlyList environments = await repository.GetByTenantIdAsync(tenantId, ct); - - List summaries = environments.Select(e => new EnvironmentSummary( - e.Id, - e.TenantId, - e.Name, - e.Slug, - e.Status.ToString(), - e.CreatedAt)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - - // POST /api/tenants/{tenantId}/environments — create a new environment. - // The tenant admin fills out a form with a name and slug (e.g. "Development" / "dev"). - - app.MapPost("/api/tenants/{tenantId:guid}/environments", async ( - Guid tenantId, - [FromBody] CreateEnvironmentRequest request, - [FromServices] IEnvironmentRepository repository, - [FromServices] ITenantRepository tenantRepository, - CancellationToken ct) => - { - // Make sure the tenant exists before creating an environment for it. - - Tenant? tenant = await tenantRepository.GetByIdAsync(tenantId, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("Tenant not found.")); - } - - // Check that the slug isn't already taken within this tenant. - - TenantEnvironment? existing = await repository.GetByTenantAndSlugAsync(tenantId, request.Slug, ct); - - if (existing is not null) - { - return Results.Conflict(ApiResponse.Fail($"An environment with slug '{request.Slug}' already exists.")); - } - - TenantEnvironment environment = TenantEnvironment.Create(tenantId, request.Name, request.Slug); - await repository.AddAsync(environment, ct); - - return Results.Ok(ApiResponse.Ok(environment.Id)); - }); - } -} - -public record EnvironmentSummary( - Guid Id, - Guid TenantId, - string Name, - string Slug, - string Status, - DateTimeOffset CreatedAt); - -public record CreateEnvironmentRequest(string Name, string Slug); diff --git a/src/EntKube.Identity/Features/GetTenantById/GetTenantByIdEndpoint.cs b/src/EntKube.Identity/Features/GetTenantById/GetTenantByIdEndpoint.cs deleted file mode 100644 index a06f231..0000000 --- a/src/EntKube.Identity/Features/GetTenantById/GetTenantByIdEndpoint.cs +++ /dev/null @@ -1,47 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Identity.Features.GetTenantById; - -/// -/// Retrieves a single tenant by its ID. The BFF calls this when -/// navigating to a specific tenant's detail page showing that -/// tenant's clusters and provisioned services. -/// -public static class GetTenantByIdEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/tenants/{id:guid}", async ( - Guid id, - [FromServices] ITenantRepository repository, - CancellationToken ct) => - { - Tenant? tenant = await repository.GetByIdAsync(id, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("Tenant not found.")); - } - - TenantDetail detail = new( - tenant.Id, - tenant.Name, - tenant.Slug, - tenant.Status.ToString(), - tenant.Members.Count, - tenant.CreatedAt); - - return Results.Ok(ApiResponse.Ok(detail)); - }); - } -} - -public record TenantDetail( - Guid Id, - string Name, - string Slug, - string Status, - int MemberCount, - DateTimeOffset CreatedAt); diff --git a/src/EntKube.Identity/Features/GetTenants/GetTenantsEndpoint.cs b/src/EntKube.Identity/Features/GetTenants/GetTenantsEndpoint.cs deleted file mode 100644 index 96c4a32..0000000 --- a/src/EntKube.Identity/Features/GetTenants/GetTenantsEndpoint.cs +++ /dev/null @@ -1,41 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Identity.Features.GetTenants; - -/// -/// Lists all tenants in the platform. The BFF calls this to populate -/// the tenant management dashboard where platform admins can see all -/// registered organizations and their status. -/// -public static class GetTenantsEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/tenants", async ( - [FromServices] ITenantRepository repository, - CancellationToken ct) => - { - IReadOnlyList tenants = await repository.GetAllAsync(ct); - - List summaries = tenants.Select(t => new TenantSummary( - t.Id, - t.Name, - t.Slug, - t.Status.ToString(), - t.Members.Count, - t.CreatedAt)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - } -} - -public record TenantSummary( - Guid Id, - string Name, - string Slug, - string Status, - int MemberCount, - DateTimeOffset CreatedAt); diff --git a/src/EntKube.Identity/Infrastructure/EfCustomerRepository.cs b/src/EntKube.Identity/Infrastructure/EfCustomerRepository.cs deleted file mode 100644 index 47726cc..0000000 --- a/src/EntKube.Identity/Infrastructure/EfCustomerRepository.cs +++ /dev/null @@ -1,47 +0,0 @@ -using EntKube.Identity.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Identity.Infrastructure; - -/// -/// EF Core implementation of the customer repository. -/// -public class EfCustomerRepository : ICustomerRepository -{ - private readonly IdentityDbContext db; - - public EfCustomerRepository(IdentityDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.Customers.FindAsync([id], ct); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken ct = default) - { - return await db.Customers - .Where(c => c.TenantId == tenantId) - .ToListAsync(ct); - } - - public async Task GetByTenantAndSlugAsync(Guid tenantId, string slug, CancellationToken ct = default) - { - return await db.Customers - .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Slug == slug, ct); - } - - public async Task AddAsync(Customer customer, CancellationToken ct = default) - { - db.Customers.Add(customer); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(Customer customer, CancellationToken ct = default) - { - db.Customers.Update(customer); - await db.SaveChangesAsync(ct); - } -} diff --git a/src/EntKube.Identity/Infrastructure/EfEnvironmentRepository.cs b/src/EntKube.Identity/Infrastructure/EfEnvironmentRepository.cs deleted file mode 100644 index 871f504..0000000 --- a/src/EntKube.Identity/Infrastructure/EfEnvironmentRepository.cs +++ /dev/null @@ -1,47 +0,0 @@ -using EntKube.Identity.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Identity.Infrastructure; - -/// -/// EF Core implementation of the environment repository. -/// -public class EfEnvironmentRepository : IEnvironmentRepository -{ - private readonly IdentityDbContext db; - - public EfEnvironmentRepository(IdentityDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.Environments.FindAsync([id], ct); - } - - public async Task> GetByTenantIdAsync(Guid tenantId, CancellationToken ct = default) - { - return await db.Environments - .Where(e => e.TenantId == tenantId) - .ToListAsync(ct); - } - - public async Task GetByTenantAndSlugAsync(Guid tenantId, string slug, CancellationToken ct = default) - { - return await db.Environments - .FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Slug == slug, ct); - } - - public async Task AddAsync(TenantEnvironment environment, CancellationToken ct = default) - { - db.Environments.Add(environment); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(TenantEnvironment environment, CancellationToken ct = default) - { - db.Environments.Update(environment); - await db.SaveChangesAsync(ct); - } -} diff --git a/src/EntKube.Identity/Infrastructure/EfTenantRepository.cs b/src/EntKube.Identity/Infrastructure/EfTenantRepository.cs deleted file mode 100644 index 7def576..0000000 --- a/src/EntKube.Identity/Infrastructure/EfTenantRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -using EntKube.Identity.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Identity.Infrastructure; - -/// -/// EF Core implementation of the tenant repository. Each method creates a -/// round-trip to the database — no in-memory caching. The DbContext is -/// scoped per request, so concurrent requests get their own context. -/// -public class EfTenantRepository : ITenantRepository -{ - private readonly IdentityDbContext db; - - public EfTenantRepository(IdentityDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.Tenants.FindAsync([id], ct); - } - - public async Task GetBySlugAsync(string slug, CancellationToken ct = default) - { - return await db.Tenants.FirstOrDefaultAsync(t => t.Slug == slug, ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.Tenants.ToListAsync(ct); - } - - public async Task AddAsync(Tenant tenant, CancellationToken ct = default) - { - db.Tenants.Add(tenant); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(Tenant tenant, CancellationToken ct = default) - { - db.Tenants.Update(tenant); - await db.SaveChangesAsync(ct); - } -} diff --git a/src/EntKube.Identity/Infrastructure/FileBackedTenantRepository.cs b/src/EntKube.Identity/Infrastructure/FileBackedTenantRepository.cs deleted file mode 100644 index f5730b1..0000000 --- a/src/EntKube.Identity/Infrastructure/FileBackedTenantRepository.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System.Text.Json; -using EntKube.Identity.Domain; - -namespace EntKube.Identity.Infrastructure; - -/// -/// A development-friendly tenant repository that keeps data in memory for fast -/// access but also persists to a JSON file on disk. This way tenants survive -/// service restarts during debugging. On startup, any previously saved tenants -/// are loaded from the file. On every write, the full state is flushed to disk. -/// -/// Production will replace this with an EF Core implementation backed by PostgreSQL. -/// -public class FileBackedTenantRepository : ITenantRepository -{ - private readonly List tenants = new(); - private readonly string filePath; - private readonly object lockObj = new(); - private static readonly JsonSerializerOptions jsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true - }; - - public FileBackedTenantRepository(IWebHostEnvironment env) - { - // Store the data file in the project's App_Data folder so it's easy to find - // and excluded from source control via .gitignore. - - string dataDir = Path.Combine(env.ContentRootPath, "App_Data"); - Directory.CreateDirectory(dataDir); - filePath = Path.Combine(dataDir, "tenants.json"); - - LoadFromDisk(); - } - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - lock (lockObj) - { - Tenant? tenant = tenants.FirstOrDefault(t => t.Id == id); - return Task.FromResult(tenant); - } - } - - public Task GetBySlugAsync(string slug, CancellationToken ct = default) - { - lock (lockObj) - { - Tenant? tenant = tenants.FirstOrDefault(t => t.Slug == slug); - return Task.FromResult(tenant); - } - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - lock (lockObj) - { - IReadOnlyList result = tenants.ToList().AsReadOnly(); - return Task.FromResult(result); - } - } - - public Task AddAsync(Tenant tenant, CancellationToken ct = default) - { - lock (lockObj) - { - tenants.Add(tenant); - FlushToDisk(); - } - - return Task.CompletedTask; - } - - public Task UpdateAsync(Tenant tenant, CancellationToken ct = default) - { - lock (lockObj) - { - FlushToDisk(); - } - - return Task.CompletedTask; - } - - // --- Persistence helpers --- - - private void LoadFromDisk() - { - if (!File.Exists(filePath)) - { - return; - } - - try - { - string json = File.ReadAllText(filePath); - List? dtos = JsonSerializer.Deserialize>(json, jsonOptions); - - if (dtos is null) - { - return; - } - - foreach (TenantDto dto in dtos) - { - // Re-hydrate through the factory method with a dummy user, - // then override internal state from persisted data. - - Tenant tenant = Tenant.Create(dto.Name, dto.Slug, dto.Members.FirstOrDefault()?.UserId ?? Guid.Empty); - - // Use reflection to restore the persisted ID and state. - - SetProperty(tenant, nameof(Tenant.Id), dto.Id); - SetProperty(tenant, nameof(Tenant.Status), dto.Status); - SetProperty(tenant, nameof(Tenant.CreatedAt), dto.CreatedAt); - - // Restore members beyond the first (the factory already added the first). - - foreach (TenantMemberDto member in dto.Members.Skip(1)) - { - tenant.AddMember(member.UserId, member.Role); - } - - tenants.Add(tenant); - } - } - catch (JsonException) - { - // If the file is corrupt, start fresh. - } - } - - private void FlushToDisk() - { - List dtos = tenants.Select(t => new TenantDto - { - Id = t.Id, - Name = t.Name, - Slug = t.Slug, - Status = t.Status, - CreatedAt = t.CreatedAt, - Members = t.Members.Select(m => new TenantMemberDto - { - UserId = m.UserId, - Role = m.Role - }).ToList() - }).ToList(); - - string json = JsonSerializer.Serialize(dtos, jsonOptions); - File.WriteAllText(filePath, json); - } - - private static void SetProperty(object obj, string propertyName, object? value) - { - System.Reflection.PropertyInfo? prop = obj.GetType().GetProperty(propertyName); - prop?.SetValue(obj, value); - } - - /// - /// DTOs for JSON serialization — keeps the domain model free of persistence concerns. - /// - private class TenantDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Slug { get; set; } = string.Empty; - public TenantStatus Status { get; set; } - public DateTimeOffset CreatedAt { get; set; } - public List Members { get; set; } = new(); - } - - private class TenantMemberDto - { - public Guid UserId { get; set; } - public TenantRole Role { get; set; } - } -} diff --git a/src/EntKube.Identity/Infrastructure/IdentityDbContext.cs b/src/EntKube.Identity/Infrastructure/IdentityDbContext.cs deleted file mode 100644 index 5594388..0000000 --- a/src/EntKube.Identity/Infrastructure/IdentityDbContext.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text.Json; -using EntKube.Identity.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Identity.Infrastructure; - -/// -/// The EF Core database context for the Identity service. Owns the Tenants table -/// and maps the Tenant aggregate root with its Members collection stored as JSON. -/// -public class IdentityDbContext : DbContext -{ - public DbSet Tenants => Set(); - public DbSet Environments => Set(); - public DbSet Customers => Set(); - - public IdentityDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // The Tenant aggregate stores its members list as a JSON column. - // This avoids a separate table for a simple collection that is always - // loaded with the aggregate and has no independent identity. - - modelBuilder.Entity(entity => - { - entity.HasKey(t => t.Id); - entity.Property(t => t.Name).IsRequired().HasMaxLength(200); - entity.Property(t => t.Slug).IsRequired().HasMaxLength(200); - entity.HasIndex(t => t.Slug).IsUnique(); - entity.Property(t => t.Status).HasConversion().HasMaxLength(50); - entity.Property(t => t.CreatedAt); - - // Members are stored as a JSON column. EF Core accesses the - // backing field directly so the private list is populated correctly. - - entity.Property>("members") - .HasField("members") - .HasColumnName("Members") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(t => t.Members); - }); - - // Environments belong to a tenant and represent deployment stages - // (dev, test, prod). The slug is unique within a tenant. - - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.Property(e => e.TenantId).IsRequired(); - entity.Property(e => e.Name).IsRequired().HasMaxLength(200); - entity.Property(e => e.Slug).IsRequired().HasMaxLength(100); - entity.HasIndex(e => new { e.TenantId, e.Slug }).IsUnique(); - entity.Property(e => e.Status).HasConversion().HasMaxLength(50); - entity.Property(e => e.CreatedAt); - }); - - // Customers represent teams or organizations within a tenant — each - // getting their own namespaces, quotas, and app deployments per environment. - - modelBuilder.Entity(entity => - { - entity.HasKey(c => c.Id); - entity.Property(c => c.TenantId).IsRequired(); - entity.Property(c => c.Name).IsRequired().HasMaxLength(200); - entity.Property(c => c.Slug).IsRequired().HasMaxLength(100); - entity.HasIndex(c => new { c.TenantId, c.Slug }).IsUnique(); - entity.Property(c => c.Status).HasConversion().HasMaxLength(50); - entity.Property(c => c.CreatedAt); - }); - } -} diff --git a/src/EntKube.Identity/Infrastructure/InMemoryTenantRepository.cs b/src/EntKube.Identity/Infrastructure/InMemoryTenantRepository.cs deleted file mode 100644 index 1eee6e5..0000000 --- a/src/EntKube.Identity/Infrastructure/InMemoryTenantRepository.cs +++ /dev/null @@ -1,41 +0,0 @@ -using EntKube.Identity.Domain; - -namespace EntKube.Identity.Infrastructure; - -/// -/// In-memory implementation of the tenant repository for local development. -/// Production will use EF Core with PostgreSQL. -/// -public class InMemoryTenantRepository : ITenantRepository -{ - private readonly List tenants = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - Tenant? tenant = tenants.FirstOrDefault(t => t.Id == id); - return Task.FromResult(tenant); - } - - public Task GetBySlugAsync(string slug, CancellationToken ct = default) - { - Tenant? tenant = tenants.FirstOrDefault(t => t.Slug == slug); - return Task.FromResult(tenant); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = tenants.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(Tenant tenant, CancellationToken ct = default) - { - tenants.Add(tenant); - return Task.CompletedTask; - } - - public Task UpdateAsync(Tenant tenant, CancellationToken ct = default) - { - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Identity/Program.cs b/src/EntKube.Identity/Program.cs deleted file mode 100644 index 7812a81..0000000 --- a/src/EntKube.Identity/Program.cs +++ /dev/null @@ -1,127 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.Identity.Features.CreateTenant; -using EntKube.Identity.Features.Customers; -using EntKube.Identity.Features.Environments; -using EntKube.Identity.Features.GetTenantById; -using EntKube.Identity.Features.GetTenants; -using EntKube.Identity.Infrastructure; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Identity; - -public class Program -{ - public static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - // Register the database context. The provider is selected based on - // the DatabaseProvider config value: Sqlite (default), Postgres, or SqlServer. - - string provider = builder.Configuration["DatabaseProvider"] ?? "Sqlite"; - string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=App_Data/identity.db"; - - builder.Services.AddDbContext(options => - { - switch (provider.ToLowerInvariant()) - { - case "postgres": - options.UseNpgsql(connectionString); - break; - - case "sqlserver": - options.UseSqlServer(connectionString); - break; - - default: - options.UseSqlite(connectionString); - break; - } - }); - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddHealthChecks(); - builder.Services.AddOpenApi(); - - WebApplication app = builder.Build(); - - // Apply pending database migrations on startup. - - MigrateDatabase(app); - - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - } - - app.UseHttpsRedirection(); - - // Map feature endpoints. - - CreateTenantEndpoint.Map(app); - GetTenantsEndpoint.Map(app); - GetTenantByIdEndpoint.Map(app); - EnvironmentEndpoints.Map(app); - CustomerEndpoints.Map(app); - - app.MapHealthChecks("/health"); - app.MapHealthChecks("/health/ready"); - - app.Run(); - } - - private static void MigrateDatabase(WebApplication app) - { - using IServiceScope scope = app.Services.CreateScope(); - IdentityDbContext db = scope.ServiceProvider.GetRequiredService(); - ILogger logger = scope.ServiceProvider.GetRequiredService>(); - - // SQLite needs the directory to exist before it can create the database file. - - string? connectionString = db.Database.GetConnectionString(); - - if (connectionString != null) - { - Microsoft.Data.Sqlite.SqliteConnectionStringBuilder csb = new(connectionString); - - if (!string.IsNullOrEmpty(csb.DataSource) && csb.DataSource != ":memory:") - { - string? directory = Path.GetDirectoryName(Path.GetFullPath(csb.DataSource)); - - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - } - } - - int maxRetries = 5; - int delayMs = 1000; - - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - db.Database.EnsureCreated(); - logger.LogInformation("Database ready on attempt {Attempt}.", attempt); - return; - } - catch (Exception ex) when (attempt < maxRetries) - { - logger.LogWarning( - ex, - "Database setup attempt {Attempt}/{MaxRetries} failed. Retrying in {DelayMs}ms...", - attempt, - maxRetries, - delayMs); - - Thread.Sleep(delayMs); - delayMs *= 2; - } - } - } -} diff --git a/src/EntKube.Identity/Properties/launchSettings.json b/src/EntKube.Identity/Properties/launchSettings.json deleted file mode 100644 index 51d489b..0000000 --- a/src/EntKube.Identity/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5030", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7030;http://localhost:5030", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/EntKube.Identity/appsettings.Development.json b/src/EntKube.Identity/appsettings.Development.json deleted file mode 100644 index 2d6cd3d..0000000 --- a/src/EntKube.Identity/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/EntKube.Identity/appsettings.json b/src/EntKube.Identity/appsettings.json deleted file mode 100644 index d0111f5..0000000 --- a/src/EntKube.Identity/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/EntKube.Provisioning/Dockerfile b/src/EntKube.Provisioning/Dockerfile deleted file mode 100644 index fb496d9..0000000 --- a/src/EntKube.Provisioning/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 5020 - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["src/EntKube.Provisioning/EntKube.Provisioning.csproj", "src/EntKube.Provisioning/"] -COPY ["src/EntKube.SharedKernel/EntKube.SharedKernel.csproj", "src/EntKube.SharedKernel/"] -RUN dotnet restore "src/EntKube.Provisioning/EntKube.Provisioning.csproj" -COPY . . -WORKDIR "/src/src/EntKube.Provisioning" -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://+:5020 -ENTRYPOINT ["dotnet", "EntKube.Provisioning.dll"] diff --git a/src/EntKube.Provisioning/Domain/App.cs b/src/EntKube.Provisioning/Domain/App.cs deleted file mode 100644 index 51ab7a7..0000000 --- a/src/EntKube.Provisioning/Domain/App.cs +++ /dev/null @@ -1,646 +0,0 @@ -using System.Text.Json.Serialization; - -namespace EntKube.Provisioning.Domain; - -/// -/// An App represents a customer's workload that can be deployed across multiple -/// environments. Think of it like an ArgoCD Application — it defines WHAT gets -/// deployed and WHERE it runs. Unlike ArgoCD, the deployment definition lives -/// here instead of in a Git repository. -/// -/// Two flavors exist: -/// 1. Deployment — generates Kubernetes manifests (Deployment, Service, HTTPRoute, Secret) -/// 2. HelmChart — installs a Helm chart from a repository with custom values -/// -/// A customer admin creates an app, then adds it to one or more environments -/// (dev, staging, prod). Each environment gets its own configuration — different -/// image tags, replica counts, hostnames, helm values, and secrets. -/// -public class App -{ - public Guid Id { get; private set; } - public Guid TenantId { get; private set; } - public Guid CustomerId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Slug { get; private set; } = string.Empty; - public AppType Type { get; private set; } - public AppStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - - // Each environment this app is deployed to gets its own configuration. - // The app owns the lifecycle of these — adding, configuring, and removing - // environments all go through the App aggregate root. - - private readonly List environments = new(); - public IReadOnlyList Environments => environments.AsReadOnly(); - - private App() { } - - /// - /// Creates a new app within a customer's organization. The customer admin - /// picks a name, a URL-safe slug, and the deployment type (raw Deployment - /// manifests or a Helm chart). The app starts active and ready to have - /// environments added. - /// - public static App Create(Guid tenantId, Guid customerId, string name, string slug, AppType type) - { - // Every app must belong to a tenant and a customer. - - if (tenantId == Guid.Empty) - { - throw new ArgumentException("Tenant ID is required.", nameof(tenantId)); - } - - if (customerId == Guid.Empty) - { - throw new ArgumentException("Customer ID is required.", nameof(customerId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("App name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(slug)) - { - throw new ArgumentException("App slug is required.", nameof(slug)); - } - - return new App - { - Id = Guid.NewGuid(), - TenantId = tenantId, - CustomerId = customerId, - Name = name, - Slug = slug.ToLowerInvariant(), - Type = type, - Status = AppStatus.Active, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - /// - /// Adds the app to a new environment. Each environment gets its own cluster, - /// namespace, and configuration. Think of this as saying "I want this app - /// running in dev on cluster-01 in the api-dev namespace." - /// - public AppEnvironment AddEnvironment(Guid environmentId, Guid clusterId, string ns) - { - // An app can only be deployed to each environment once. - - if (environments.Any(e => e.EnvironmentId == environmentId)) - { - throw new InvalidOperationException( - $"App is already deployed to environment '{environmentId}'."); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - AppEnvironment env = AppEnvironment.Create(Id, environmentId, clusterId, ns); - environments.Add(env); - return env; - } - - /// - /// Removes the app from an environment. The reconciler will handle - /// cleaning up the Kubernetes resources. - /// - public void RemoveEnvironment(Guid environmentId) - { - AppEnvironment? env = environments.FirstOrDefault(e => e.EnvironmentId == environmentId); - - if (env is null) - { - throw new InvalidOperationException( - $"Environment '{environmentId}' not found on this app."); - } - - environments.Remove(env); - } - - /// - /// Suspends the app. New deployments and syncs are blocked until reactivated. - /// - public void Suspend() - { - Status = AppStatus.Suspended; - } - - /// - /// Re-activates a previously suspended app. - /// - public void Activate() - { - Status = AppStatus.Active; - } -} - -/// -/// Whether the app is deployed via raw Kubernetes manifests or a Helm chart. -/// -public enum AppType -{ - Deployment, - HelmChart -} - -/// -/// Whether the app is currently accepting deployments. -/// -public enum AppStatus -{ - Active, - Suspended -} - -/// -/// Represents a deployment of an app to a specific environment. Each environment -/// gets its own configuration — a Deployment-type app gets a DeploymentSpec, -/// a HelmChart-type app gets a HelmReleaseSpec. Both can have secrets that -/// reference keys stored in the secrets vault. -/// -/// The sync status tracks whether the running state matches the desired state, -/// much like ArgoCD's sync status indicator. -/// -public class AppEnvironment -{ - public Guid Id { get; private set; } - public Guid AppId { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public string Namespace { get; private set; } = string.Empty; - - // Deployment-type configuration — only set for AppType.Deployment apps. - - public DeploymentSpec? DeploymentSpec { get; private set; } - - // Helm-type configuration — only set for AppType.HelmChart apps. - - public HelmReleaseSpec? HelmReleaseSpec { get; private set; } - - // Secrets map environment variable names to vault keys. The reconciler - // resolves the actual values from the vault at sync time using - // SecretScope.ForApp(tenantId, customerId, appSlug, environmentId). - - private readonly List secrets = new(); - public IReadOnlyList Secrets => secrets.AsReadOnly(); - - // Sync status tracks whether the Kubernetes resources match the desired config. - - public AppSyncStatus SyncStatus { get; private set; } - public string? SyncStatusMessage { get; private set; } - public DateTimeOffset? LastSyncAt { get; private set; } - - private AppEnvironment() { } - - /// - /// Creates a new environment deployment entry for an app. - /// Called by App.AddEnvironment — not intended for direct use. - /// - internal static AppEnvironment Create(Guid appId, Guid environmentId, Guid clusterId, string ns) - { - return new AppEnvironment - { - Id = Guid.NewGuid(), - AppId = appId, - EnvironmentId = environmentId, - ClusterId = clusterId, - Namespace = ns, - SyncStatus = AppSyncStatus.Pending - }; - } - - /// - /// Configures the Kubernetes Deployment, Service, and HTTPRoute specs for this - /// environment. This is the equivalent of hand-writing deployment.yaml, - /// service.yaml, and httproute.yaml — but stored as structured data that - /// the reconciler will render into actual manifests. - /// - public void ConfigureDeployment(DeploymentSpec spec) - { - DeploymentSpec = spec ?? throw new ArgumentNullException(nameof(spec)); - SyncStatus = AppSyncStatus.Pending; - } - - /// - /// Configures the Helm chart details for this environment. The reconciler - /// will run `helm upgrade --install` with these settings. - /// - public void ConfigureHelmRelease(HelmReleaseSpec spec) - { - HelmReleaseSpec = spec ?? throw new ArgumentNullException(nameof(spec)); - SyncStatus = AppSyncStatus.Pending; - } - - /// - /// Adds a secret reference. The name becomes the key in the Kubernetes Secret - /// resource; the vaultKey is used to look up the actual value in the vault - /// at sync time. - /// - public void AddSecret(string name, string vaultKey) - { - if (secrets.Any(s => s.Name == name)) - { - throw new InvalidOperationException( - $"A secret with name '{name}' already exists in this environment."); - } - - secrets.Add(new AppSecret(name, vaultKey)); - SyncStatus = AppSyncStatus.Pending; - } - - /// - /// Removes a secret reference by name. - /// - public void RemoveSecret(string name) - { - AppSecret? secret = secrets.FirstOrDefault(s => s.Name == name); - - if (secret is null) - { - throw new InvalidOperationException( - $"Secret '{name}' not found in this environment."); - } - - secrets.Remove(secret); - SyncStatus = AppSyncStatus.Pending; - } - - /// - /// The reconciler calls this after successfully deploying the app to - /// the cluster — all manifests or helm release match the desired state. - /// - public void MarkSynced() - { - SyncStatus = AppSyncStatus.Synced; - SyncStatusMessage = null; - LastSyncAt = DateTimeOffset.UtcNow; - } - - /// - /// The reconciler detected that the running state no longer matches - /// the desired configuration (e.g. someone manually edited a resource). - /// - public void MarkOutOfSync() - { - SyncStatus = AppSyncStatus.OutOfSync; - LastSyncAt = DateTimeOffset.UtcNow; - } - - /// - /// Something went wrong during deployment — image pull failures, - /// invalid helm values, insufficient cluster resources, etc. - /// - public void MarkError(string message) - { - SyncStatus = AppSyncStatus.Error; - SyncStatusMessage = message; - LastSyncAt = DateTimeOffset.UtcNow; - } - - /// - /// Manually marks the environment as pending so the reconciler picks - /// it up on the next cycle. Used for "sync now" / "retry" actions - /// from the UI after fixing errors or forcing a re-deploy. - /// - public void MarkPending() - { - SyncStatus = AppSyncStatus.Pending; - SyncStatusMessage = null; - } -} - -// ─── Value Objects ─────────────────────────────────────────────────────── - -/// -/// Defines what a Deployment-type app looks like in a given environment. -/// The reconciler uses this to generate deployment.yaml, service.yaml, -/// and httproute.yaml manifests for the target cluster. -/// -/// The primary container is defined by Image/Tag/ContainerPort. Additional -/// containers (init containers, sidecars) live in the Containers property. -/// Volumes, security context, probes, config maps, service account, and -/// pod-level metadata (annotations/labels) round out the full Kubernetes -/// Deployment specification. -/// -public record DeploymentSpec( - string Image, - string Tag, - int Replicas, - int ContainerPort, - int ServicePort, - string? HostName, - string? PathPrefix, - Dictionary? EnvironmentVariables, - ResourceSpec? Resources, - List? Volumes = null, - AdditionalContainersSpec? Containers = null, - PodSecurityContextSpec? SecurityContext = null, - string? ServiceAccountName = null, - Dictionary? Annotations = null, - Dictionary? Labels = null, - ProbesSpec? Probes = null, - List? ConfigMaps = null, - List? Services = null, - List? Routes = null, - ImageRegistryConfig? ImageRegistry = null); - -/// -/// CPU and memory resource requests/limits for a container. -/// Values use Kubernetes notation: "100m", "256Mi", "1Gi", etc. -/// -public record ResourceSpec( - string? CpuRequest, - string? CpuLimit, - string? MemoryRequest, - string? MemoryLimit); - -// ─── Volumes ───────────────────────────────────────────────────────────── - -/// -/// Defines a volume and where it mounts in the primary container. -/// The volume source determines whether it's backed by a PVC, ConfigMap, -/// Secret, EmptyDir, or HostPath. -/// -public record VolumeSpec( - string Name, - VolumeSource Source, - string? MountPath = null, - string? SubPath = null, - bool ReadOnly = false); - -/// -/// Base type for volume sources. Each subtype maps to a Kubernetes volume type. -/// Uses JSON type discriminator for polymorphic serialization in the database. -/// -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(VolumePvcSource), "pvc")] -[JsonDerivedType(typeof(VolumeConfigMapSource), "configMap")] -[JsonDerivedType(typeof(VolumeSecretSource), "secret")] -[JsonDerivedType(typeof(VolumeEmptyDirSource), "emptyDir")] -public abstract record VolumeSource; - -/// -/// Mounts an existing PersistentVolumeClaim by name. -/// -public record VolumePvcSource(string ClaimName) : VolumeSource; - -/// -/// Mounts a ConfigMap as files in a directory. -/// -public record VolumeConfigMapSource(string ConfigMapName) : VolumeSource; - -/// -/// Mounts a Secret as files in a directory. -/// -public record VolumeSecretSource(string SecretName) : VolumeSource; - -/// -/// An ephemeral directory that starts empty and is cleaned up when the pod dies. -/// Useful for temp files, caches, or sharing data between containers. -/// -public record VolumeEmptyDirSource(string? SizeLimit = null) : VolumeSource; - -// ─── Security ──────────────────────────────────────────────────────────── - -/// -/// Pod-level security context. Controls the user/group the containers run as, -/// the filesystem group for volume permissions, and whether root is disallowed. -/// -public record PodSecurityContextSpec( - long? RunAsUser = null, - long? RunAsGroup = null, - long? FsGroup = null, - bool? RunAsNonRoot = null); - -/// -/// Container-level security context. Restricts what a single container can do: -/// read-only root filesystem, privilege escalation, and Linux capabilities. -/// -public record ContainerSecurityContextSpec( - bool? ReadOnlyRootFilesystem = null, - bool? AllowPrivilegeEscalation = null, - List? DropCapabilities = null, - List? AddCapabilities = null); - -// ─── Additional Containers ─────────────────────────────────────────────── - -/// -/// Groups init containers and sidecar containers. Init containers run -/// sequentially before the main container starts (e.g., DB migrations, -/// config rendering). Sidecars run alongside the main container for the -/// entire pod lifetime (e.g., log shippers, proxies). -/// -public record AdditionalContainersSpec( - List? InitContainers = null, - List? Sidecars = null); - -/// -/// Defines a container — used for init containers and sidecars. -/// Similar to the primary container but with optional command/args overrides -/// and its own resource limits, env vars, volume mounts, and security context. -/// -public record ContainerSpec( - string Name, - string Image, - string Tag, - int ContainerPort, - List? Command = null, - List? Args = null, - Dictionary? EnvironmentVariables = null, - ResourceSpec? Resources = null, - List? VolumeMounts = null, - ContainerSecurityContextSpec? SecurityContext = null); - -/// -/// A volume mount within a container — references a volume by name and -/// specifies where to mount it inside the container filesystem. -/// -public record VolumeMount( - string Name, - string MountPath, - string? SubPath = null, - bool ReadOnly = false); - -// ─── Probes ────────────────────────────────────────────────────────────── - -/// -/// Health check probes for the primary container. Kubernetes uses these -/// to determine when to restart (liveness), remove from service (readiness), -/// and when the app has finished starting up (startup). -/// -public record ProbesSpec( - ProbeSpec? Liveness = null, - ProbeSpec? Readiness = null, - ProbeSpec? Startup = null); - -/// -/// A single probe definition. Supports HTTP GET, TCP socket, and exec probes. -/// Type should be "HTTP", "TCP", or "Exec". -/// -public record ProbeSpec( - string Type, - string? Path, - int Port, - int InitialDelaySeconds = 0, - int PeriodSeconds = 10, - int FailureThreshold = 3, - List? Command = null); - -// ─── ConfigMaps ────────────────────────────────────────────────────────── - -/// -/// Defines a ConfigMap resource. The reconciler generates a ConfigMap manifest -/// from this data. Use VolumeConfigMapSource to mount it into a container. -/// -public record ConfigMapSpec( - string Name, - Dictionary Data); - -// ─── Services ──────────────────────────────────────────────────────────── - -/// -/// Defines a Kubernetes Service resource. Each app can expose multiple services — -/// for example an HTTP API on port 80 and a gRPC endpoint on port 9090. -/// The Protocol field controls whether ports use TCP (default) or UDP. -/// The Type field controls the service type: ClusterIP (default), NodePort, -/// or LoadBalancer. -/// -public record ServiceSpec( - string Name, - int Port, - int TargetPort, - string Protocol = "TCP", - string Type = "ClusterIP"); - -// ─── Gateway API Routes ────────────────────────────────────────────────── - -/// -/// Defines a Gateway API route resource. Supports all Gateway API route types: -/// - HTTP → HTTPRoute (L7 routing with path/header matching) -/// - TLS → TLSRoute (TLS passthrough by SNI hostname) -/// - TCP → TCPRoute (raw TCP, port-based) -/// - UDP → UDPRoute (raw UDP, port-based) -/// - GRPC → GRPCRoute (gRPC-native routing) -/// -/// Each route can reference a specific gateway and define multiple rules -/// with matches, backend refs, and filters. -/// -public record RouteSpec( - string Name, - string Type, - List? Hostnames, - GatewayReference? GatewayRef, - List Rules); - -/// -/// References the Gateway resource that this route attaches to. -/// If null, the route uses the cluster's default gateway. -/// -public record GatewayReference( - string Name, - string? Namespace = null); - -/// -/// A single routing rule. Each rule has optional matches (path, headers), -/// one or more backend references (with optional weights for traffic splitting), -/// and optional filters (header modification, redirects, URL rewrites). -/// -public record RouteRule( - List? Matches, - List BackendRefs, - List? Filters); - -/// -/// Defines how incoming requests are matched to this rule. -/// MatchType is "Exact", "PathPrefix", or "RegularExpression". -/// Headers is an optional dictionary for header-based matching. -/// -public record RouteMatch( - string MatchType, - string Value, - Dictionary? Headers); - -/// -/// A backend service that traffic is forwarded to. Weight is used for -/// canary deployments / traffic splitting — if null, traffic is distributed -/// evenly. The weight is relative to other backend refs in the same rule. -/// -public record RouteBackendRef( - string Name, - int Port, - int? Weight); - -/// -/// A filter applied to traffic before forwarding. Type determines what -/// the filter does: -/// - RequestHeaderModifier: adds/sets headers (HeadersToSet) -/// - URLRewrite: rewrites the request path (RewritePath) -/// - RequestRedirect: redirects the request (RedirectUrl) -/// -public record RouteFilter( - string Type, - Dictionary? HeadersToSet, - string? RewritePath, - string? RedirectUrl); - -/// -/// Defines what a HelmChart-type app looks like in a given environment. -/// The reconciler uses this to run `helm upgrade --install` against -/// the target cluster with the specified chart and values. -/// -public record HelmReleaseSpec( - string RepoUrl, - string ChartName, - string ChartVersion, - string? ValuesYaml); - -// ─── Image Registry ────────────────────────────────────────────────────── - -/// -/// Configures where the app's container images are pulled from. When the source -/// is Harbor, the reconciler automatically creates a robot account and -/// imagePullSecret so the pod can authenticate to the registry. The pull secret -/// name follows the convention "harbor-pull-{project}-{appSlug}". -/// -/// For non-Harbor registries, the admin provides an existing pull secret name. -/// -public record ImageRegistryConfig( - ImageRegistrySource Source, - string? HarborDomain = null, - string? HarborProject = null, - string? PullSecretName = null); - -/// -/// Where container images are pulled from: -/// - Public: no pull secret needed (docker.io public images) -/// - Harbor: uses the cluster's Harbor instance — pull secret auto-created -/// - Custom: external registry with a manually configured pull secret -/// -public enum ImageRegistrySource -{ - Public, - Harbor, - Custom -} - -/// -/// A reference mapping a Kubernetes Secret key to a vault key. -/// The actual secret value lives in the vault, scoped to -/// Tenant → Customer → App → Environment. -/// -public record AppSecret(string Name, string VaultKey); - -/// -/// Tracks whether the Kubernetes resources match the desired configuration. -/// -public enum AppSyncStatus -{ - Pending, - Synced, - OutOfSync, - Error -} diff --git a/src/EntKube.Provisioning/Domain/GrafanaInstance.cs b/src/EntKube.Provisioning/Domain/GrafanaInstance.cs deleted file mode 100644 index c1258bc..0000000 --- a/src/EntKube.Provisioning/Domain/GrafanaInstance.cs +++ /dev/null @@ -1,201 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// A GrafanaInstance represents the centralized visualization platform for an -/// environment. There is ONE Grafana per environment, deployed to a specific -/// cluster in that environment. It connects to Prometheus instances running -/// on each cluster in the environment, providing a unified dashboard view. -/// -/// Grafana owns: OIDC authentication, ingress routing, and dashboard definitions. -/// It's separate from PrometheusStack because Prometheus is per-cluster (local -/// metrics collection) while Grafana is per-environment (centralized visualization). -/// -public class GrafanaInstance -{ - public Guid Id { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public GrafanaInstanceStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - // Persistence keeps dashboards and preferences across pod restarts. - - public bool Persistence { get; private set; } - public string PersistenceSize { get; private set; } = "10Gi"; - - // OIDC replaces local admin login with SSO. - - public OidcConfiguration? Oidc { get; private set; } - - // How Grafana is published to users. - - public GrafanaIngressConfiguration? Ingress { get; private set; } - - // Dashboards deployed as ConfigMaps on the cluster. - - public IReadOnlyList Dashboards => dashboards.AsReadOnly(); - private List dashboards = new(); - - private GrafanaInstance() { } - - /// - /// Provisions a new Grafana instance for an environment, deployed to a specific - /// cluster. The instance starts in Pending state. The reconciliation loop - /// deploys the actual Helm release and configures data sources for each - /// Prometheus instance in the environment. - /// - public static GrafanaInstance Create(Guid environmentId, Guid clusterId, string name, string ns) - { - if (environmentId == Guid.Empty) - { - throw new ArgumentException("An environment must be specified.", nameof(environmentId)); - } - - if (clusterId == Guid.Empty) - { - throw new ArgumentException("A cluster must be specified.", nameof(clusterId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Instance name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - return new GrafanaInstance - { - Id = Guid.NewGuid(), - EnvironmentId = environmentId, - ClusterId = clusterId, - Name = name, - Namespace = ns, - Status = GrafanaInstanceStatus.Pending, - CreatedAt = DateTimeOffset.UtcNow, - Persistence = false, - PersistenceSize = "10Gi" - }; - } - - /// - /// Adjusts persistence settings — whether Grafana keeps data across pod restarts - /// and how much disk to allocate for it. - /// - public void ConfigurePersistence(bool enabled, string? size = null) - { - Persistence = enabled; - - if (size is not null) - { - PersistenceSize = size; - } - } - - /// - /// Configures OIDC authentication for Grafana. This replaces the default - /// local admin login with SSO — users authenticate through their identity - /// provider (Entra ID, Keycloak, etc.). The client secret is stored as a - /// reference to the secrets service, never as plain text. - /// - public void ConfigureOidc(OidcConfiguration oidc) - { - if (string.IsNullOrWhiteSpace(oidc.ClientId)) - { - throw new ArgumentException("OIDC client ID is required.", nameof(oidc)); - } - - if (string.IsNullOrWhiteSpace(oidc.AuthUrl)) - { - throw new ArgumentException("OIDC auth URL is required.", nameof(oidc)); - } - - if (string.IsNullOrWhiteSpace(oidc.TokenUrl)) - { - throw new ArgumentException("OIDC token URL is required.", nameof(oidc)); - } - - Oidc = oidc; - } - - /// - /// Removes OIDC from Grafana, reverting to local admin authentication. - /// - public void RemoveOidc() - { - Oidc = null; - } - - /// - /// Configures how Grafana is published to users — via Gateway API HTTPRoutes, - /// Traefik IngressRoutes, or Istio VirtualServices. TLS can use a manually-provided - /// secret or cert-manager with Let's Encrypt. - /// - public void ConfigureIngress(GrafanaIngressConfiguration ingress) - { - Ingress = ingress; - } - - /// - /// Removes Grafana's ingress routing — it becomes internal-only. - /// - public void DisableIngress() - { - Ingress = null; - } - - /// - /// Adds a Grafana dashboard. Dashboards are JSON definitions deployed as - /// ConfigMaps with the grafana_dashboard=1 label so the sidecar discovers - /// and imports them automatically. - /// - public void AddDashboard(GrafanaDashboard dashboard) - { - if (dashboards.Any(d => d.Name.Equals(dashboard.Name, StringComparison.OrdinalIgnoreCase))) - { - throw new InvalidOperationException($"Dashboard '{dashboard.Name}' already exists."); - } - - dashboards.Add(dashboard); - } - - /// - /// Removes a dashboard by name. The corresponding ConfigMap will be deleted - /// from the cluster on the next reconciliation. - /// - public void RemoveDashboard(string name) - { - dashboards.RemoveAll(d => d.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// The reconciliation loop confirmed Grafana is healthy and accessible. - /// - public void MarkRunning() - { - Status = GrafanaInstanceStatus.Running; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// The reconciliation loop detected Grafana is unhealthy. - /// - public void MarkDegraded() - { - Status = GrafanaInstanceStatus.Degraded; - LastReconcileAt = DateTimeOffset.UtcNow; - } -} - -public enum GrafanaInstanceStatus -{ - Pending, - Running, - Degraded, - Decommissioned -} diff --git a/src/EntKube.Provisioning/Domain/IAppRepository.cs b/src/EntKube.Provisioning/Domain/IAppRepository.cs deleted file mode 100644 index 6c6999c..0000000 --- a/src/EntKube.Provisioning/Domain/IAppRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Defines how the provisioning service persists and retrieves apps. -/// Apps are the unit of deployment for customer workloads — each app -/// can be deployed across multiple environments with different configs. -/// -public interface IAppRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetAllActiveAsync(CancellationToken ct = default); - Task> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default); - Task GetByCustomerAndSlugAsync(Guid customerId, string slug, CancellationToken ct = default); - Task AddAsync(App app, CancellationToken ct = default); - Task UpdateAsync(App app, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IGrafanaInstanceRepository.cs b/src/EntKube.Provisioning/Domain/IGrafanaInstanceRepository.cs deleted file mode 100644 index 6a6b6ee..0000000 --- a/src/EntKube.Provisioning/Domain/IGrafanaInstanceRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Persistence contract for Grafana instances. Each environment gets one Grafana, -/// so lookups by environment ID are supported alongside the standard ID-based access. -/// -public interface IGrafanaInstanceRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task SaveAsync(GrafanaInstance instance, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IIdentityRealmRepository.cs b/src/EntKube.Provisioning/Domain/IIdentityRealmRepository.cs deleted file mode 100644 index 75c064d..0000000 --- a/src/EntKube.Provisioning/Domain/IIdentityRealmRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Repository for managing identity realm persistence. -/// The primary query is by environment (ownership), with cluster lookup for operational needs. -/// -public interface IIdentityRealmRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task AddAsync(IdentityRealm realm, CancellationToken ct = default); - Task UpdateAsync(IdentityRealm realm, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IMinioInstanceRepository.cs b/src/EntKube.Provisioning/Domain/IMinioInstanceRepository.cs deleted file mode 100644 index 3d90a21..0000000 --- a/src/EntKube.Provisioning/Domain/IMinioInstanceRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Defines persistence operations for adopted MinIO instances. -/// -public interface IMinioInstanceRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(MinioInstance instance, CancellationToken ct = default); - Task UpdateAsync(MinioInstance instance, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IMongoClusterRepository.cs b/src/EntKube.Provisioning/Domain/IMongoClusterRepository.cs deleted file mode 100644 index bb7bea9..0000000 --- a/src/EntKube.Provisioning/Domain/IMongoClusterRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Defines persistence operations for MongoDB clusters managed by the platform. -/// The primary query is by environment (ownership), with cluster lookup for operational needs. -/// -public interface IMongoClusterRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(MongoCluster cluster, CancellationToken ct = default); - Task UpdateAsync(MongoCluster cluster, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IPostgresClusterRepository.cs b/src/EntKube.Provisioning/Domain/IPostgresClusterRepository.cs deleted file mode 100644 index cf006a9..0000000 --- a/src/EntKube.Provisioning/Domain/IPostgresClusterRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Defines persistence operations for PostgreSQL clusters managed by the platform. -/// The primary query is by environment (ownership), with cluster lookup for operational needs. -/// -public interface IPostgresClusterRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(PostgresCluster cluster, CancellationToken ct = default); - Task UpdateAsync(PostgresCluster cluster, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IPrometheusStackRepository.cs b/src/EntKube.Provisioning/Domain/IPrometheusStackRepository.cs deleted file mode 100644 index 6cfbc60..0000000 --- a/src/EntKube.Provisioning/Domain/IPrometheusStackRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Persistence contract for Prometheus stacks. Each cluster gets one Prometheus stack, -/// so lookups by cluster ID are supported alongside the standard ID-based access. -/// -public interface IPrometheusStackRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task SaveAsync(PrometheusStack stack, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IRedisClusterRepository.cs b/src/EntKube.Provisioning/Domain/IRedisClusterRepository.cs deleted file mode 100644 index 853bdc9..0000000 --- a/src/EntKube.Provisioning/Domain/IRedisClusterRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Defines persistence operations for Redis clusters managed by the platform. -/// The primary query is by environment (ownership), with cluster lookup for operational needs. -/// -public interface IRedisClusterRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(RedisCluster cluster, CancellationToken ct = default); - Task UpdateAsync(RedisCluster cluster, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IServiceInstanceRepository.cs b/src/EntKube.Provisioning/Domain/IServiceInstanceRepository.cs deleted file mode 100644 index 4d6db4f..0000000 --- a/src/EntKube.Provisioning/Domain/IServiceInstanceRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Defines how the provisioning service persists and retrieves service instances. -/// The primary query is by environment (ownership), with cluster lookup for operational needs. -/// -public interface IServiceInstanceRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default); - Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(ServiceInstance instance, CancellationToken ct = default); - Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Domain/IdentityRealm.cs b/src/EntKube.Provisioning/Domain/IdentityRealm.cs deleted file mode 100644 index 852b029..0000000 --- a/src/EntKube.Provisioning/Domain/IdentityRealm.cs +++ /dev/null @@ -1,334 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Represents an identity realm (backed by Keycloak) that belongs to an environment. -/// A realm is an isolated authentication space with its own users, identity providers, -/// organizations, branding, and enabled pages. -/// -/// The realm belongs to an environment and physically runs on one of the clusters -/// within that environment. Each environment can have many realms. Each realm can have: -/// - Multiple identity providers (OIDC, SAML, social logins) -/// - Hierarchical organizations within the realm -/// - Custom branding (logo, background, colors) -/// - Page selection (which pages are enabled: login, profile, etc.) -/// -/// Lifecycle: Provisioning → Active → Disabled → Decommissioned -/// -public class IdentityRealm -{ - public Guid Id { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public string Name { get; private set; } = string.Empty; - public IdentityRealmStatus Status { get; private set; } - public string? StatusMessage { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastModifiedAt { get; private set; } - - // Branding for the realm's pages (login, profile, etc.) - - public RealmBranding Branding { get; private set; } = RealmBranding.Default; - - // Which pages are enabled for this realm. - - public RealmPages Pages { get; private set; } = RealmPages.Default; - - // Identity providers configured for this realm. - - private readonly List identityProviders = new(); - public IReadOnlyList IdentityProviders => identityProviders.AsReadOnly(); - - // Organizations within this realm (hierarchical). - - private readonly List organizations = new(); - public IReadOnlyList Organizations => organizations.AsReadOnly(); - - private IdentityRealm() { } - - /// - /// Creates a new identity realm within an environment. The realm physically runs - /// on the Keycloak instance deployed on the specified hosting cluster. - /// - public static IdentityRealm Create(Guid environmentId, string name, Guid clusterId) - { - if (environmentId == Guid.Empty) - { - throw new ArgumentException("Environment ID is required.", nameof(environmentId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Realm name cannot be empty.", nameof(name)); - } - - if (clusterId == Guid.Empty) - { - throw new ArgumentException("Cluster ID is required.", nameof(clusterId)); - } - - return new IdentityRealm - { - Id = Guid.NewGuid(), - EnvironmentId = environmentId, - ClusterId = clusterId, - Name = name, - Status = IdentityRealmStatus.Provisioning, - CreatedAt = DateTimeOffset.UtcNow, - Branding = RealmBranding.Default, - Pages = RealmPages.Default - }; - } - - /// - /// Updates the realm's branding (logo, background, colors). - /// - public void UpdateBranding(RealmBranding branding) - { - Branding = branding; - LastModifiedAt = DateTimeOffset.UtcNow; - } - - /// - /// Sets which pages are enabled for this realm. - /// - public void UpdatePages(RealmPages pages) - { - Pages = pages; - LastModifiedAt = DateTimeOffset.UtcNow; - } - - /// - /// Adds an identity provider to this realm (OIDC, SAML, social, etc.) - /// - public void AddIdentityProvider(RealmIdentityProvider provider) - { - if (identityProviders.Any(p => p.Alias == provider.Alias)) - { - throw new InvalidOperationException($"Identity provider with alias '{provider.Alias}' already exists."); - } - - identityProviders.Add(provider); - LastModifiedAt = DateTimeOffset.UtcNow; - } - - /// - /// Removes an identity provider by alias. - /// - public void RemoveIdentityProvider(string alias) - { - RealmIdentityProvider? existing = identityProviders.FirstOrDefault(p => p.Alias == alias); - - if (existing is null) - { - throw new InvalidOperationException($"Identity provider with alias '{alias}' not found."); - } - - identityProviders.Remove(existing); - LastModifiedAt = DateTimeOffset.UtcNow; - } - - /// - /// Adds a top-level organization to this realm. - /// - public void AddOrganization(RealmOrganization organization) - { - if (organizations.Any(o => o.Name == organization.Name)) - { - throw new InvalidOperationException($"Organization '{organization.Name}' already exists."); - } - - organizations.Add(organization); - LastModifiedAt = DateTimeOffset.UtcNow; - } - - /// - /// Removes an organization by name. - /// - public void RemoveOrganization(string name) - { - RealmOrganization? existing = organizations.FirstOrDefault(o => o.Name == name); - - if (existing is null) - { - throw new InvalidOperationException($"Organization '{name}' not found."); - } - - organizations.Remove(existing); - LastModifiedAt = DateTimeOffset.UtcNow; - } - - public void MarkActive() - { - Status = IdentityRealmStatus.Active; - StatusMessage = null; - LastModifiedAt = DateTimeOffset.UtcNow; - } - - public void MarkDisabled(string? reason = null) - { - Status = IdentityRealmStatus.Disabled; - StatusMessage = reason; - LastModifiedAt = DateTimeOffset.UtcNow; - } -} - -public enum IdentityRealmStatus -{ - Provisioning, - Active, - Disabled, - Decommissioned -} - -/// -/// Branding configuration for a realm's pages. -/// Controls logo, background, and color scheme. -/// -public record RealmBranding( - string? LogoUrl, - string? BackgroundImageUrl, - string? BackgroundColor, - string? PrimaryColor, - string? SecondaryColor, - string? TextColor) -{ - public static RealmBranding Default => new(null, null, null, null, null, null); -} - -/// -/// Configures which pages are enabled for this realm. -/// At minimum, login is always enabled. Profile, registration, etc. are optional. -/// -public record RealmPages( - bool LoginEnabled, - bool ProfileEnabled, - bool RegistrationEnabled, - bool ForgotPasswordEnabled) -{ - public static RealmPages Default => new( - LoginEnabled: true, - ProfileEnabled: false, - RegistrationEnabled: false, - ForgotPasswordEnabled: true); -} - -/// -/// An identity provider configured within a realm. -/// Supports OIDC, SAML, and social providers (Google, GitHub, etc.) -/// -public class RealmIdentityProvider -{ - public Guid Id { get; private set; } - public string Alias { get; private set; } = string.Empty; - public string DisplayName { get; private set; } = string.Empty; - public IdentityProviderType Type { get; private set; } - public bool Enabled { get; private set; } = true; - - // OIDC/SAML configuration fields. - - public string? AuthorizationUrl { get; private set; } - public string? TokenUrl { get; private set; } - public string? ClientId { get; private set; } - public string? ClientSecret { get; private set; } - public string? MetadataUrl { get; private set; } - - private RealmIdentityProvider() { } - - public static RealmIdentityProvider Create( - string alias, - string displayName, - IdentityProviderType type, - string? authorizationUrl = null, - string? tokenUrl = null, - string? clientId = null, - string? clientSecret = null, - string? metadataUrl = null) - { - if (string.IsNullOrWhiteSpace(alias)) - { - throw new ArgumentException("Alias cannot be empty.", nameof(alias)); - } - - return new RealmIdentityProvider - { - Id = Guid.NewGuid(), - Alias = alias, - DisplayName = displayName, - Type = type, - AuthorizationUrl = authorizationUrl, - TokenUrl = tokenUrl, - ClientId = clientId, - ClientSecret = clientSecret, - MetadataUrl = metadataUrl - }; - } -} - -public enum IdentityProviderType -{ - Oidc, - Saml, - Google, - GitHub, - Microsoft, - Custom -} - -/// -/// An organization within a realm. Organizations support hierarchy — -/// each can have child organizations forming a tree structure. -/// -public class RealmOrganization -{ - public Guid Id { get; private set; } - public string Name { get; private set; } = string.Empty; - public string? Description { get; private set; } - - private readonly List children = new(); - public IReadOnlyList Children => children.AsReadOnly(); - - private RealmOrganization() { } - - public static RealmOrganization Create(string name, string? description = null) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Organization name cannot be empty.", nameof(name)); - } - - return new RealmOrganization - { - Id = Guid.NewGuid(), - Name = name, - Description = description - }; - } - - /// - /// Adds a child organization under this one. - /// - public void AddChild(RealmOrganization child) - { - if (children.Any(c => c.Name == child.Name)) - { - throw new InvalidOperationException($"Child organization '{child.Name}' already exists."); - } - - children.Add(child); - } - - /// - /// Removes a child organization by name. - /// - public void RemoveChild(string name) - { - RealmOrganization? existing = children.FirstOrDefault(c => c.Name == name); - - if (existing is null) - { - throw new InvalidOperationException($"Child organization '{name}' not found."); - } - - children.Remove(existing); - } -} diff --git a/src/EntKube.Provisioning/Domain/MinioInstance.cs b/src/EntKube.Provisioning/Domain/MinioInstance.cs deleted file mode 100644 index 0aa22bd..0000000 --- a/src/EntKube.Provisioning/Domain/MinioInstance.cs +++ /dev/null @@ -1,172 +0,0 @@ -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Domain; - -/// -/// Represents an adopted MinIO object storage deployment on a Kubernetes cluster. -/// MinIO is a hard dependency for CNPG (WAL archiving and base backups) so the platform -/// must be aware of where MinIO lives before PostgreSQL clusters can be provisioned. -/// -/// This aggregate tracks: -/// - **Connection details**: endpoint, console URL, credentials secret -/// - **Capacity**: total and used storage for visibility -/// - **Buckets**: which buckets exist (CNPG clusters create one per cluster) -/// - **Health**: Running/Degraded status from reconciler -/// -/// MinIO instances are always adopted (discovered from existing deployments), -/// never provisioned from scratch by this service — the operator is installed -/// via the Clusters service, and the tenant already exists on the K8s cluster. -/// -public class MinioInstance -{ - public Guid Id { get; private set; } - public Guid ClusterId { get; private set; } - public Guid EnvironmentId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public string Endpoint { get; private set; } = string.Empty; - public string? ConsoleEndpoint { get; private set; } - public string CredentialsSecret { get; private set; } = string.Empty; - public string? TotalCapacity { get; private set; } - public string? UsedCapacity { get; private set; } - public MinioInstanceStatus Status { get; private set; } - public string? StatusMessage { get; private set; } - public DateTimeOffset AdoptedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - private readonly List buckets = new(); - public IReadOnlyList Buckets => buckets.AsReadOnly(); - - private MinioInstance() { } - - /// - /// Adopts an existing MinIO deployment discovered on the Kubernetes cluster. - /// Since MinIO is always pre-existing (installed by the Clusters service operator), - /// we only support adoption — not provisioning from scratch. - /// - /// The discovered details tell us how to connect (endpoint, credentials) and what - /// buckets are already in use. CNPG clusters will reference this instance when - /// configuring WAL archiving. - /// - public static MinioInstance Adopt( - Guid clusterId, - string name, - string ns, - string endpoint, - string? consoleEndpoint, - string credentialsSecret, - string? totalCapacity, - string? usedCapacity, - List? buckets, - Guid environmentId = default) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("MinIO instance name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(endpoint)) - { - throw new ArgumentException("MinIO endpoint is required.", nameof(endpoint)); - } - - if (string.IsNullOrWhiteSpace(credentialsSecret)) - { - throw new ArgumentException("MinIO credentials secret name is required.", nameof(credentialsSecret)); - } - - MinioInstance instance = new() - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - Endpoint = endpoint, - ConsoleEndpoint = consoleEndpoint, - CredentialsSecret = credentialsSecret, - TotalCapacity = totalCapacity, - UsedCapacity = usedCapacity, - Status = MinioInstanceStatus.Running, - AdoptedAt = DateTimeOffset.UtcNow, - LastReconcileAt = DateTimeOffset.UtcNow - }; - - if (buckets is not null) - { - instance.buckets.AddRange(buckets); - } - - return instance; - } - - /// - /// Registers a new bucket on this MinIO instance. Called when CNPG provisions - /// a new cluster and needs a dedicated backup bucket created. - /// - public Result CreateBucket(string bucketName) - { - if (Status != MinioInstanceStatus.Running) - { - return Result.Failure($"Cannot create bucket: MinIO must be running (current: {Status})."); - } - - if (buckets.Contains(bucketName, StringComparer.OrdinalIgnoreCase)) - { - return Result.Failure($"Bucket '{bucketName}' already exists on this MinIO instance."); - } - - buckets.Add(bucketName); - - return Result.Success($"Bucket '{bucketName}' created."); - } - - /// - /// Returns the connection details that CNPG needs to configure WAL archiving - /// and base backups targeting this MinIO instance. - /// - public MinioBackupTarget GetBackupTarget(string bucket) - { - return new MinioBackupTarget( - Endpoint: Endpoint, - Bucket: bucket, - CredentialsSecret: CredentialsSecret); - } - - public void MarkRunning() - { - Status = MinioInstanceStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - public void MarkDegraded(string reason) - { - Status = MinioInstanceStatus.Degraded; - StatusMessage = reason; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - public void UpdateCapacity(string? totalCapacity, string? usedCapacity) - { - TotalCapacity = totalCapacity; - UsedCapacity = usedCapacity; - LastReconcileAt = DateTimeOffset.UtcNow; - } -} - -public enum MinioInstanceStatus -{ - Running, - Degraded, - Decommissioned -} - -/// -/// The connection details CNPG needs to send WAL/backups to MinIO. -/// This is what PostgresCluster.Create() needs as input. -/// -public record MinioBackupTarget( - string Endpoint, - string Bucket, - string CredentialsSecret); diff --git a/src/EntKube.Provisioning/Domain/MinioTenant.cs b/src/EntKube.Provisioning/Domain/MinioTenant.cs deleted file mode 100644 index f107911..0000000 --- a/src/EntKube.Provisioning/Domain/MinioTenant.cs +++ /dev/null @@ -1,315 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Represents a MinIO Tenant managed by the MinIO Operator on a Kubernetes cluster. -/// A tenant is an independent storage cluster with its own pools, credentials, and -/// capacity. One operator installation can host many tenants — each in its own namespace. -/// -/// The tenant's storage is configured through pools. Each pool defines: -/// - How many servers (pods) to run -/// - How many volumes (disks) each server mounts -/// - The size and storage class for each volume -/// -/// Lifecycle: Provisioning → Running → Degraded → Decommissioned -/// -/// Provisioning creates the Tenant CRD on the cluster. The MinIO Operator then -/// reconciles: creates StatefulSets, PVCs, and Services. Once all pods are ready, -/// the reconciler marks the tenant as Running. -/// -public class MinioTenant -{ - public Guid Id { get; private set; } - public Guid ClusterId { get; private set; } - public Guid EnvironmentId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public MinioTenantStatus Status { get; private set; } - public string? StatusMessage { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - private readonly List pools = new(); - public IReadOnlyList Pools => pools.AsReadOnly(); - - private MinioTenant() { } - - /// - /// Creates a new MinIO Tenant specification. This doesn't touch Kubernetes yet — - /// it validates the desired configuration and creates the domain aggregate. - /// The handler will then apply the Tenant CRD to the cluster. - /// - /// A tenant needs at least one pool. Each pool must have at least 1 server - /// and 1 volume per server. MinIO's erasure coding works best with 4+ servers - /// and 4+ volumes per server, but we allow any positive configuration. - /// - public static MinioTenant Create( - Guid clusterId, - string name, - string ns, - List pools, - Guid environmentId = default) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Tenant name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Tenant namespace is required.", nameof(ns)); - } - - if (pools is null || pools.Count == 0) - { - throw new ArgumentException("At least one pool is required.", nameof(pools)); - } - - // Validate each pool's configuration. - - foreach (MinioTenantPool pool in pools) - { - if (pool.Servers < 1) - { - throw new ArgumentException("Each pool must have at least 1 server.", nameof(pools)); - } - - if (pool.VolumesPerServer < 1) - { - throw new ArgumentException("Each pool must have at least 1 volume per server.", nameof(pools)); - } - - if (string.IsNullOrWhiteSpace(pool.StorageSize)) - { - throw new ArgumentException("Storage size is required for each pool.", nameof(pools)); - } - } - - MinioTenant tenant = new() - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - Status = MinioTenantStatus.Provisioning, - CreatedAt = DateTimeOffset.UtcNow - }; - - tenant.pools.AddRange(pools); - - return tenant; - } - - /// - /// Adopts an existing MinIO Tenant discovered on the Kubernetes cluster. - /// Unlike Create(), this doesn't provision anything new — the tenant is already - /// running. We just import it into the domain so we can track and manage it. - /// - /// Adopted tenants start in Running state because they were already serving - /// traffic when we discovered them. If health checks later reveal issues, - /// the reconciler will move them to Degraded. - /// - public static MinioTenant Adopt( - Guid clusterId, - string name, - string ns, - List pools, - Guid environmentId = default) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Tenant name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Tenant namespace is required.", nameof(ns)); - } - - if (pools is null || pools.Count == 0) - { - throw new ArgumentException("At least one pool is required.", nameof(pools)); - } - - MinioTenant tenant = new() - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - Status = MinioTenantStatus.Running, - CreatedAt = DateTimeOffset.UtcNow, - LastReconcileAt = DateTimeOffset.UtcNow - }; - - tenant.pools.AddRange(pools); - - return tenant; - } - - /// - /// The operator has confirmed all pods are ready and the tenant is serving traffic. - /// - public void MarkRunning() - { - Status = MinioTenantStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Something is wrong — drives offline, pods crashing, or rebalancing in progress. - /// The tenant may still serve traffic but with reduced redundancy. - /// - public void MarkDegraded(string reason) - { - Status = MinioTenantStatus.Degraded; - StatusMessage = reason; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Replaces the pool configuration. Used when scaling out (adding servers) - /// or changing storage class. The operator will reconcile the new spec, - /// so we move back to Provisioning until it confirms. - /// - public void UpdatePools(List newPools) - { - if (newPools is null || newPools.Count == 0) - { - throw new ArgumentException("At least one pool is required.", nameof(newPools)); - } - - pools.Clear(); - pools.AddRange(newPools); - Status = MinioTenantStatus.Provisioning; - StatusMessage = "Pool configuration updated — waiting for operator reconciliation."; - } - - /// - /// Marks the tenant for teardown. The handler will delete the Tenant CRD - /// from the cluster, and the operator will clean up StatefulSets and PVCs. - /// - public void Decommission() - { - Status = MinioTenantStatus.Decommissioned; - StatusMessage = "Tenant marked for deletion."; - } - - /// - /// Returns the S3-compatible endpoint for this tenant. - /// MinIO tenants expose storage at: {name}-hl.{namespace}.svc:9000 - /// - public string GetEndpoint() - { - return $"{Name}-hl.{Namespace}.svc:9000"; - } - - /// - /// Returns the MinIO Console endpoint for this tenant. - /// Console is at: {name}-console.{namespace}.svc:9443 - /// - public string GetConsoleEndpoint() - { - return $"{Name}-console.{Namespace}.svc:9443"; - } - - /// - /// Returns the conventional credentials secret name for this tenant. - /// The operator creates this secret with accessKey and secretKey. - /// - public string GetCredentialsSecretName() - { - return $"{Name}-secret"; - } - - /// - /// Calculates total raw capacity across all pools. - /// Formula: sum of (servers × volumesPerServer × storageSize) per pool. - /// Returns a Kubernetes-style quantity string (e.g., "1600Gi"). - /// - public string CalculateTotalCapacity() - { - // Parse storage sizes and sum across all pools. - // Supports Gi and Ti suffixes. - - long totalGi = 0; - - foreach (MinioTenantPool pool in pools) - { - long sizeGi = ParseStorageSizeToGi(pool.StorageSize); - totalGi += pool.Servers * pool.VolumesPerServer * sizeGi; - } - - if (totalGi >= 1024 && totalGi % 1024 == 0) - { - return $"{totalGi / 1024}Ti"; - } - - return $"{totalGi}Gi"; - } - - private static long ParseStorageSizeToGi(string storageSize) - { - // Handle common Kubernetes quantity suffixes. - - if (storageSize.EndsWith("Ti", StringComparison.OrdinalIgnoreCase)) - { - return long.Parse(storageSize[..^2]) * 1024; - } - - if (storageSize.EndsWith("Gi", StringComparison.OrdinalIgnoreCase)) - { - return long.Parse(storageSize[..^2]); - } - - if (storageSize.EndsWith("Mi", StringComparison.OrdinalIgnoreCase)) - { - return long.Parse(storageSize[..^2]) / 1024; - } - - // Assume Gi if no suffix. - - return long.Parse(storageSize); - } -} - -/// -/// The lifecycle states of a MinIO Tenant. -/// -public enum MinioTenantStatus -{ - /// Tenant CRD applied — waiting for operator to create pods and PVCs. - Provisioning, - - /// All pods running, tenant serving traffic normally. - Running, - - /// Some issue detected — drives offline, pods crashing, or healing in progress. - Degraded, - - /// Tenant marked for deletion — operator cleaning up resources. - Decommissioned -} - -/// -/// Defines one pool within a MinIO Tenant. A pool is a set of identical servers, -/// each mounting the same number of volumes with the same size and storage class. -/// -/// MinIO's erasure coding distributes data across all volumes in the pool. -/// For production, 4 servers × 4 volumes is the minimum recommended configuration -/// (gives N/2 parity for data protection). -/// -/// Resource requests/limits control how much CPU and memory each MinIO server pod gets. -/// These map directly to the container resource spec in the Kubernetes pod template. -/// -public record MinioTenantPool( - int Servers, - int VolumesPerServer, - string StorageSize, - string StorageClass, - string? CpuRequest = null, - string? CpuLimit = null, - string? MemoryRequest = null, - string? MemoryLimit = null); diff --git a/src/EntKube.Provisioning/Domain/MongoCluster.cs b/src/EntKube.Provisioning/Domain/MongoCluster.cs deleted file mode 100644 index d0434b5..0000000 --- a/src/EntKube.Provisioning/Domain/MongoCluster.cs +++ /dev/null @@ -1,333 +0,0 @@ -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Domain; - -/// -/// The MongoCluster aggregate root represents a MongoDB Community Operator cluster -/// managed by the platform. This is a rich domain model that tracks the full lifecycle -/// of a MongoDB replica set deployment on Kubernetes: -/// -/// - **Creation**: Defines desired state (version, members, storage, backup config) -/// - **Adoption**: Imports an existing cluster already running on K8s -/// - **Database management**: Tracks databases provisioned within the cluster -/// - **Upgrades**: Controlled version transitions with featureCompatibilityVersion safety -/// - **Backup/Restore**: MinIO-backed mongodump scheduled backups -/// - **Scaling**: Member count changes for HA (replica set members) -/// -/// The aggregate enforces invariants: you can't add databases to a non-running cluster, -/// you can't upgrade a cluster that's not healthy, and MinIO backup is always required. -/// -/// The MongoDB Community Operator manages MongoDBCommunity CRDs which deploy a -/// replica set with authentication, TLS, and arbiter support. -/// -public class MongoCluster -{ - public Guid Id { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public string MongoVersion { get; private set; } = string.Empty; - public int Members { get; private set; } - public string StorageSize { get; private set; } = string.Empty; - public MongoClusterStatus Status { get; private set; } - public string? StatusMessage { get; private set; } - public string? TargetVersion { get; private set; } - public MongoBackupConfiguration? BackupConfig { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - private readonly List databases = new(); - public IReadOnlyList Databases => databases.AsReadOnly(); - - private MongoCluster() { } - - /// - /// Creates a new MongoDB cluster request within an environment. The cluster - /// belongs to the environment and physically runs on the specified hosting cluster. - /// It starts in Provisioning state — the reconciliation loop will pick it up and - /// create the MongoDBCommunity resource on the target Kubernetes cluster. - /// - /// Backup configuration is optional. When S3/MinIO details are provided, the platform - /// will create a CronJob for scheduled mongodump backups. When omitted, the cluster - /// runs without automated backups — the user can configure them later. - /// - /// Think of this like filling out a "new MongoDB cluster" form: you specify what - /// you want (version, size, replicas) and the platform takes care of deploying it. - /// The MongoDB Community Operator will create a replica set with the specified - /// number of members, configure authentication via SCRAM, and set up persistent storage. - /// - public static MongoCluster Create( - Guid environmentId, - Guid clusterId, - string name, - string ns, - string mongoVersion, - int members, - string storageSize, - string? minioEndpoint = null, - string? minioBucket = null, - string? minioCredentialsSecret = null) - { - // Validate all required inputs. A cluster without these is undefined. - - if (environmentId == Guid.Empty) - { - throw new ArgumentException("Environment ID is required.", nameof(environmentId)); - } - - if (clusterId == Guid.Empty) - { - throw new ArgumentException("Cluster ID is required.", nameof(clusterId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Cluster name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - if (members < 1) - { - throw new ArgumentException("Must have at least 1 member.", nameof(members)); - } - - // Backup configuration is optional. If S3/MinIO details are provided, validate - // them as a complete set. If none are provided, the cluster runs without backups. - - bool hasBackupConfig = !string.IsNullOrWhiteSpace(minioEndpoint) && - !string.IsNullOrWhiteSpace(minioBucket) && - !string.IsNullOrWhiteSpace(minioCredentialsSecret); - - MongoBackupConfiguration? backupConfig = hasBackupConfig - ? new MongoBackupConfiguration( - MinioEndpoint: minioEndpoint!, - Bucket: minioBucket!, - CredentialsSecret: minioCredentialsSecret!, - Schedule: "0 0 * * *", - RetentionDays: 7) - : null; - - return new MongoCluster - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - MongoVersion = mongoVersion, - Members = members, - StorageSize = storageSize, - Status = MongoClusterStatus.Provisioning, - CreatedAt = DateTimeOffset.UtcNow, - BackupConfig = backupConfig - }; - } - - /// - /// Adopts an existing MongoDB cluster that's already running on the Kubernetes - /// cluster. Unlike Create, the cluster starts in Running state because we're - /// importing something that already exists — not deploying from scratch. - /// - /// This is like "registering" an existing MongoDB cluster with the platform so - /// it can be managed, monitored, and have databases added to it going forward. - /// - public static MongoCluster Adopt( - Guid environmentId, - Guid clusterId, - string name, - string ns, - string mongoVersion, - int members, - string storageSize, - string minioEndpoint, - string minioBucket, - string minioCredentialsSecret, - List? databases = null) - { - MongoCluster cluster = Create( - environmentId, clusterId, name, ns, mongoVersion, members, storageSize, - minioEndpoint, minioBucket, minioCredentialsSecret); - - // Override the status since this cluster is already running. - - cluster.Status = MongoClusterStatus.Running; - cluster.LastReconcileAt = DateTimeOffset.UtcNow; - - // Import any databases that already exist on the cluster. - - if (databases is not null) - { - cluster.databases.AddRange(databases); - } - - return cluster; - } - - /// - /// The reconciler confirmed the cluster is ready and the replica set is healthy. - /// - public void MarkRunning() - { - Status = MongoClusterStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// The reconciler detected a problem — member not ready, replication lag, etc. - /// - public void MarkDegraded(string reason) - { - Status = MongoClusterStatus.Degraded; - StatusMessage = reason; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Adds a new database to this cluster. The database will be created by running - /// a mongo shell command against the primary to initialize the database and - /// create the owner user with readWrite role. - /// - /// Rules: - /// - Cluster must be Running (can't add DBs to a degraded or provisioning cluster) - /// - Database name must be unique within the cluster - /// - public Result AddDatabase(string databaseName, string ownerRole) - { - if (Status != MongoClusterStatus.Running) - { - return Result.Failure($"Cannot add database: cluster must be running (current: {Status})."); - } - - if (databases.Contains(databaseName, StringComparer.OrdinalIgnoreCase)) - { - return Result.Failure($"Database '{databaseName}' already exists on this cluster."); - } - - databases.Add(databaseName); - - return Result.Success($"Database '{databaseName}' with owner '{ownerRole}' queued for creation."); - } - - /// - /// Removes a database from the aggregate's tracked list. The actual drop - /// happens via a K8s Job — this just updates the domain state. - /// - public void RemoveDatabase(string databaseName) - { - databases.RemoveAll(d => d.Equals(databaseName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Requests a controlled MongoDB version upgrade. The reconciler will handle - /// the actual rolling upgrade process: the Community Operator updates members - /// one by one, starting with secondaries, then stepping down the primary. - /// - /// MongoDB version upgrades must respect featureCompatibilityVersion (FCV). - /// Major version jumps (e.g., 5.0 → 7.0) require intermediate steps. - /// The operator handles this — we just need to validate the cluster is healthy. - /// - /// Rules: - /// - Cluster must be Running - /// - Target version must differ from current version - /// - public Result RequestUpgrade(string targetVersion) - { - if (Status != MongoClusterStatus.Running) - { - return Result.Failure($"Cannot upgrade: cluster must be running (current: {Status})."); - } - - if (targetVersion == MongoVersion) - { - return Result.Failure($"Cluster is already at the same version ({MongoVersion})."); - } - - Status = MongoClusterStatus.Upgrading; - TargetVersion = targetVersion; - - return Result.Success($"Upgrade to MongoDB {targetVersion} initiated."); - } - - /// - /// The reconciler confirmed the upgrade completed successfully. Updates the - /// version to the target and transitions back to Running. - /// - public void CompleteUpgrade() - { - if (TargetVersion is not null) - { - MongoVersion = TargetVersion; - } - - TargetVersion = null; - Status = MongoClusterStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Changes the number of replica set members. - /// The reconciler will update the MongoDBCommunity spec accordingly. - /// MongoDB replica sets should have an odd number of members for proper - /// election, but we don't enforce this — the operator handles arbiters. - /// - public Result ScaleMembers(int newCount) - { - if (newCount < 1) - { - return Result.Failure("Must have at least 1 member."); - } - - int previousCount = Members; - Members = newCount; - - return Result.Success($"Scaled from {previousCount} to {newCount} members."); - } - - /// - /// Updates the backup schedule and retention policy. The reconciler will - /// apply this to the backup CronJob that runs mongodump to MinIO. - /// - public void ConfigureBackupSchedule(string cronSchedule, int retentionDays) - { - if (BackupConfig is not null) - { - BackupConfig = BackupConfig with - { - Schedule = cronSchedule, - RetentionDays = retentionDays - }; - } - } -} - -/// -/// Lifecycle states for a MongoDB cluster managed by the platform. -/// -public enum MongoClusterStatus -{ - Provisioning, - Running, - Degraded, - Upgrading, - Decommissioned -} - -/// -/// Configuration for mongodump-based scheduled backups to MinIO. -/// Every MongoDB cluster MUST have backup configured — this is a hard requirement. -/// Unlike PostgreSQL WAL archiving, MongoDB uses periodic full dumps (mongodump) -/// written to object storage. Point-in-time recovery requires oplog replay. -/// -public record MongoBackupConfiguration( - string MinioEndpoint, - string Bucket, - string CredentialsSecret, - string Schedule, - int RetentionDays); diff --git a/src/EntKube.Provisioning/Domain/MonitoringValueObjects.cs b/src/EntKube.Provisioning/Domain/MonitoringValueObjects.cs deleted file mode 100644 index 120db05..0000000 --- a/src/EntKube.Provisioning/Domain/MonitoringValueObjects.cs +++ /dev/null @@ -1,114 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// Prometheus configuration — controls retention, scaling, and storage. -/// -public record PrometheusConfiguration( - string Retention, - string? RetentionSize, - int Replicas, - string StorageSize) -{ - public static PrometheusConfiguration Default() => - new(Retention: "30d", RetentionSize: "45GB", Replicas: 2, StorageSize: "50Gi"); -} - -/// -/// Alertmanager configuration — controls whether it's deployed and how many replicas. -/// -public record AlertmanagerConfiguration( - bool Enabled, - int Replicas) -{ - public static AlertmanagerConfiguration Default() => - new(Enabled: true, Replicas: 2); -} - -/// -/// OIDC configuration for services that support SSO (currently Grafana). -/// The ClientSecretRef points to the secrets service (e.g., vault://path/to/secret) -/// rather than storing the secret inline — this integrates with the built-in -/// secrets feature. -/// -public record OidcConfiguration( - string Provider, - string ClientId, - string ClientSecretRef, - string AuthUrl, - string TokenUrl, - string ApiUrl, - string Scopes, - string? RoleAttributePath, - bool AutoLogin); - -/// -/// Ingress configuration for publishing Prometheus and Alertmanager via Gateway API, -/// Traefik, or Istio. TLS can use a manually-provided secret or cert-manager with -/// Let's Encrypt. The gateway name and namespace are resolved automatically from the -/// cluster's ingress component at reconciliation time. -/// -public record IngressConfiguration( - bool Enabled, - IngressProvider Provider, - string? PrometheusHostname, - string? AlertmanagerHostname, - bool TlsEnabled, - TlsCertificateMode TlsCertificateMode = TlsCertificateMode.Manual, - string? TlsSecretName = null, - string? ClusterIssuer = null); - -/// -/// Grafana-specific ingress configuration. Published separately because Grafana -/// is its own component with OIDC, dashboards, and dedicated routing. -/// -public record GrafanaIngressConfiguration( - bool Enabled, - IngressProvider Provider, - string? Hostname, - bool TlsEnabled, - TlsCertificateMode TlsCertificateMode = TlsCertificateMode.Manual, - string? TlsSecretName = null, - string? ClusterIssuer = null); - -public enum IngressProvider -{ - GatewayApi, - Traefik, - Istio -} - -/// -/// How TLS certificates are provisioned for ingress. -/// Manual means you provide a pre-existing K8s TLS secret name. -/// CertManager means cert-manager with a ClusterIssuer (e.g., Let's Encrypt) -/// automatically provisions and renews the certificate. -/// -public enum TlsCertificateMode -{ - Manual, - CertManager -} - -/// -/// A Grafana dashboard definition. Deployed as a ConfigMap with the -/// grafana_dashboard=1 label so the sidecar automatically discovers it. -/// Categories help organize dashboards in the UI. -/// -public record GrafanaDashboard( - string Name, - string DisplayName, - DashboardCategory Category, - string JsonContent); - -public enum DashboardCategory -{ - Kubernetes, - Istio, - ArgoCD, - MinIO, - RabbitMQ, - CoreDNS, - CertManager, - Operations, - Custom -} diff --git a/src/EntKube.Provisioning/Domain/PostgresCluster.cs b/src/EntKube.Provisioning/Domain/PostgresCluster.cs deleted file mode 100644 index e8bfabd..0000000 --- a/src/EntKube.Provisioning/Domain/PostgresCluster.cs +++ /dev/null @@ -1,324 +0,0 @@ -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Domain; - -/// -/// The PostgresCluster aggregate root represents a CloudNativePG PostgreSQL cluster -/// managed by the platform. This is a rich domain model that tracks the full lifecycle -/// of a PostgreSQL deployment on Kubernetes: -/// -/// - **Creation**: Defines desired state (version, instances, storage, backup config) -/// - **Adoption**: Imports an existing cluster already running on K8s -/// - **Database management**: Tracks databases provisioned within the cluster -/// - **Upgrades**: Controlled major/minor version transitions -/// - **Backup/WAL**: MinIO-backed WAL archiving and scheduled backups -/// - **Scaling**: Instance count changes for HA and read replicas -/// -/// The aggregate enforces invariants: you can't add databases to a non-running cluster, -/// you can't upgrade a cluster that's not healthy, and MinIO backup is always required. -/// -public class PostgresCluster -{ - public Guid Id { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public string PostgresVersion { get; private set; } = string.Empty; - public int Instances { get; private set; } - public string StorageSize { get; private set; } = string.Empty; - public PostgresClusterStatus Status { get; private set; } - public string? StatusMessage { get; private set; } - public string? TargetVersion { get; private set; } - public BackupConfiguration? BackupConfig { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - private readonly List databases = new(); - public IReadOnlyList Databases => databases.AsReadOnly(); - - private PostgresCluster() { } - - /// - /// Creates a new PostgreSQL cluster request within an environment. The cluster - /// belongs to the environment and physically runs on the specified hosting cluster. - /// It starts in Provisioning state — the reconciliation loop will pick it up and - /// create the CNPG Cluster resource on the target Kubernetes cluster. - /// - /// Backup configuration is optional. When S3/MinIO details are provided, the platform - /// configures WAL archiving and scheduled backups via Barman. When omitted, the cluster - /// runs without automated backups — the user can configure them later. - /// - public static PostgresCluster Create( - Guid environmentId, - Guid clusterId, - string name, - string ns, - string postgresVersion, - int instances, - string storageSize, - string? minioEndpoint = null, - string? minioBucket = null, - string? minioCredentialsSecret = null, - string? s3Region = null) - { - // Validate all required inputs. A cluster without these is undefined. - - if (environmentId == Guid.Empty) - { - throw new ArgumentException("Environment ID is required.", nameof(environmentId)); - } - - if (clusterId == Guid.Empty) - { - throw new ArgumentException("Cluster ID is required.", nameof(clusterId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Cluster name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - if (instances < 1) - { - throw new ArgumentException("Must have at least 1 instance.", nameof(instances)); - } - - // Backup configuration is optional. If S3/MinIO details are provided, validate - // them as a complete set and enable WAL archiving. If none are provided, the - // cluster runs without automated backups. - - bool hasBackupConfig = !string.IsNullOrWhiteSpace(minioEndpoint) && - !string.IsNullOrWhiteSpace(minioBucket) && - !string.IsNullOrWhiteSpace(minioCredentialsSecret); - - BackupConfiguration? backupConfig = hasBackupConfig - ? new BackupConfiguration( - MinioEndpoint: minioEndpoint!, - Bucket: minioBucket!, - CredentialsSecret: minioCredentialsSecret!, - WalArchivingEnabled: true, - Schedule: "0 0 * * *", - RetentionDays: 7, - S3Region: s3Region) - : null; - - return new PostgresCluster - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - PostgresVersion = postgresVersion, - Instances = instances, - StorageSize = storageSize, - Status = PostgresClusterStatus.Provisioning, - CreatedAt = DateTimeOffset.UtcNow, - BackupConfig = backupConfig - }; - } - - /// - /// Adopts an existing PostgreSQL cluster that's already running on the Kubernetes - /// cluster. Unlike Create, the cluster starts in Running state because we're - /// importing something that already exists — not deploying from scratch. - /// - /// This is like "registering" an existing database cluster with the platform so - /// it can be managed, monitored, and have databases added to it going forward. - /// - public static PostgresCluster Adopt( - Guid environmentId, - Guid clusterId, - string name, - string ns, - string postgresVersion, - int instances, - string storageSize, - string minioEndpoint, - string minioBucket, - string minioCredentialsSecret, - List? databases = null, - string? s3Region = null) - { - PostgresCluster cluster = Create( - environmentId, clusterId, name, ns, postgresVersion, instances, storageSize, - minioEndpoint, minioBucket, minioCredentialsSecret, - s3Region: s3Region); - - // Override the status since this cluster is already running. - - cluster.Status = PostgresClusterStatus.Running; - cluster.LastReconcileAt = DateTimeOffset.UtcNow; - - // Import any databases that already exist on the cluster. - - if (databases is not null) - { - cluster.databases.AddRange(databases); - } - - return cluster; - } - - /// - /// The reconciler confirmed the cluster is ready and accepting connections. - /// - public void MarkRunning() - { - Status = PostgresClusterStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// The reconciler detected a problem — primary not ready, replication lag, etc. - /// - public void MarkDegraded(string reason) - { - Status = PostgresClusterStatus.Degraded; - StatusMessage = reason; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Adds a new database to this cluster. The database will be created by the - /// reconciler applying SQL commands or updating the CNPG Cluster spec. - /// An application can request a database and the platform provisions it. - /// - /// Rules: - /// - Cluster must be Running (can't add DBs to a degraded or provisioning cluster) - /// - Database name must be unique within the cluster - /// - public Result AddDatabase(string databaseName, string ownerRole) - { - if (Status != PostgresClusterStatus.Running) - { - return Result.Failure($"Cannot add database: cluster must be running (current: {Status})."); - } - - if (databases.Contains(databaseName, StringComparer.OrdinalIgnoreCase)) - { - return Result.Failure($"Database '{databaseName}' already exists on this cluster."); - } - - databases.Add(databaseName); - - return Result.Success($"Database '{databaseName}' with owner '{ownerRole}' queued for creation."); - } - - /// - /// Removes a database from the aggregate's tracked list. The actual DROP - /// happens via a K8s Job — this just updates the domain state. - /// - public void RemoveDatabase(string databaseName) - { - databases.RemoveAll(d => d.Equals(databaseName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Requests a controlled PostgreSQL version upgrade. The reconciler will handle - /// the actual rolling upgrade process (primary switchover, replica updates). - /// - /// Rules: - /// - Cluster must be Running - /// - Target version must differ from current version - /// - public Result RequestUpgrade(string targetVersion) - { - if (Status != PostgresClusterStatus.Running) - { - return Result.Failure($"Cannot upgrade: cluster must be running (current: {Status})."); - } - - if (targetVersion == PostgresVersion) - { - return Result.Failure($"Cluster is already at the same version ({PostgresVersion})."); - } - - Status = PostgresClusterStatus.Upgrading; - TargetVersion = targetVersion; - - return Result.Success($"Upgrade to PostgreSQL {targetVersion} initiated."); - } - - /// - /// The reconciler confirmed the upgrade completed successfully. Updates the - /// version to the target and transitions back to Running. - /// - public void CompleteUpgrade() - { - if (TargetVersion is not null) - { - PostgresVersion = TargetVersion; - } - - TargetVersion = null; - Status = PostgresClusterStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Changes the number of PostgreSQL instances (primary + replicas). - /// The reconciler will update the CNPG Cluster spec accordingly. - /// - public Result ScaleInstances(int newCount) - { - if (newCount < 1) - { - return Result.Failure("Must have at least 1 instance."); - } - - int previousCount = Instances; - Instances = newCount; - - return Result.Success($"Scaled from {previousCount} to {newCount} instances."); - } - - /// - /// Updates the backup schedule and retention policy. The reconciler will - /// apply this to the CNPG ScheduledBackup resource. - /// - public void ConfigureBackupSchedule(string cronSchedule, int retentionDays) - { - if (BackupConfig is not null) - { - BackupConfig = BackupConfig with - { - Schedule = cronSchedule, - RetentionDays = retentionDays - }; - } - } -} - -/// -/// Lifecycle states for a PostgreSQL cluster managed by the platform. -/// -public enum PostgresClusterStatus -{ - Provisioning, - Running, - Degraded, - Upgrading, - Decommissioned -} - -/// -/// Configuration for WAL archiving and scheduled backups to MinIO. -/// Every CNPG cluster MUST have backup configured — this is a hard requirement. -/// -public record BackupConfiguration( - string MinioEndpoint, - string Bucket, - string CredentialsSecret, - bool WalArchivingEnabled, - string Schedule, - int RetentionDays, - string? S3Region = null); diff --git a/src/EntKube.Provisioning/Domain/PrometheusStack.cs b/src/EntKube.Provisioning/Domain/PrometheusStack.cs deleted file mode 100644 index 1cf777f..0000000 --- a/src/EntKube.Provisioning/Domain/PrometheusStack.cs +++ /dev/null @@ -1,156 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// A PrometheusStack represents the metrics collection and alerting infrastructure -/// deployed on a single Kubernetes cluster — Prometheus for scraping and storing metrics, -/// and Alertmanager for routing alerts. Each cluster in an environment gets its own -/// PrometheusStack because metrics collection must be local to the cluster. -/// -/// Grafana (visualization) lives separately as a GrafanaInstance per environment, -/// which connects to all Prometheus instances in that environment's clusters. -/// -public class PrometheusStack -{ - public Guid Id { get; private set; } - public Guid ClusterId { get; private set; } - public Guid EnvironmentId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public PrometheusStackStatus Status { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - // Prometheus collects metrics; Alertmanager routes alerts. - - public PrometheusConfiguration Prometheus { get; private set; } = null!; - public AlertmanagerConfiguration Alertmanager { get; private set; } = null!; - - // How Prometheus and Alertmanager are published to users. - - public IngressConfiguration? Ingress { get; private set; } - - private PrometheusStack() { } - - /// - /// Provisions a new Prometheus stack on a cluster. The stack starts in Pending - /// state with production defaults — 2 replicas, 30 days retention, 50Gi storage, - /// Alertmanager enabled. The reconciliation loop deploys the actual Helm release. - /// - public static PrometheusStack Create(Guid clusterId, Guid environmentId, string name, string ns) - { - if (clusterId == Guid.Empty) - { - throw new ArgumentException("A cluster must be specified.", nameof(clusterId)); - } - - if (environmentId == Guid.Empty) - { - throw new ArgumentException("An environment must be specified.", nameof(environmentId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Stack name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - return new PrometheusStack - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - Status = PrometheusStackStatus.Pending, - CreatedAt = DateTimeOffset.UtcNow, - Prometheus = PrometheusConfiguration.Default(), - Alertmanager = AlertmanagerConfiguration.Default() - }; - } - - /// - /// Adjusts the Prometheus configuration — how long to keep data, how many - /// replicas to run, and how much disk to allocate. Only provided values - /// are changed; nulls leave the current setting untouched. - /// - public void ConfigurePrometheus( - string? retention = null, - int? replicas = null, - string? storageSize = null, - string? retentionSize = null) - { - if (replicas.HasValue && replicas.Value < 1) - { - throw new ArgumentException("At least one Prometheus replica is required.", nameof(replicas)); - } - - Prometheus = new PrometheusConfiguration( - Retention: retention ?? Prometheus.Retention, - RetentionSize: retentionSize ?? Prometheus.RetentionSize, - Replicas: replicas ?? Prometheus.Replicas, - StorageSize: storageSize ?? Prometheus.StorageSize); - } - - /// - /// Adjusts Alertmanager settings — whether it's running and how many replicas. - /// Disabling Alertmanager means no alert routing, which is fine for dev clusters - /// but risky for production. - /// - public void ConfigureAlertmanager(bool? enabled = null, int? replicas = null) - { - Alertmanager = new AlertmanagerConfiguration( - Enabled: enabled ?? Alertmanager.Enabled, - Replicas: replicas ?? Alertmanager.Replicas); - } - - /// - /// Configures how Prometheus and Alertmanager are published to users — via Gateway API - /// HTTPRoutes, Traefik IngressRoutes, or Istio VirtualServices. TLS can use a - /// manually-provided secret or cert-manager with Let's Encrypt. - /// - public void ConfigureIngress(IngressConfiguration ingress) - { - Ingress = ingress; - } - - /// - /// Removes ingress routing — Prometheus and Alertmanager become internal-only, - /// accessible only via kubectl port-forward or cluster-internal DNS. - /// - public void DisableIngress() - { - Ingress = null; - } - - /// - /// The reconciliation loop confirmed the Prometheus stack is healthy — - /// all components are running as expected. - /// - public void MarkRunning() - { - Status = PrometheusStackStatus.Running; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// The reconciliation loop detected that one or more components are - /// unhealthy — perhaps Prometheus can't scrape, or Alertmanager lost quorum. - /// - public void MarkDegraded() - { - Status = PrometheusStackStatus.Degraded; - LastReconcileAt = DateTimeOffset.UtcNow; - } -} - -public enum PrometheusStackStatus -{ - Pending, - Running, - Degraded, - Decommissioned -} diff --git a/src/EntKube.Provisioning/Domain/RedisCluster.cs b/src/EntKube.Provisioning/Domain/RedisCluster.cs deleted file mode 100644 index 826b006..0000000 --- a/src/EntKube.Provisioning/Domain/RedisCluster.cs +++ /dev/null @@ -1,230 +0,0 @@ -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Domain; - -/// -/// The RedisCluster aggregate root represents a Redis instance managed by the -/// OT-OPERATORS Redis Operator on Kubernetes. This rich domain model tracks the -/// full lifecycle of a Redis deployment: -/// -/// - **Creation**: Defines desired state (version, replicas, storage, sentinel mode) -/// - **Adoption**: Imports an existing Redis CR already running on K8s -/// - **Upgrades**: Controlled version transitions via image tag update -/// - **Scaling**: Replica count changes for read capacity and HA -/// - **Sentinel**: Optional HA mode with automatic failover -/// -/// The OT-OPERATORS Redis Operator manages Redis, RedisCluster, RedisReplication, -/// and RedisSentinel CRDs. This aggregate represents a single Redis instance -/// (standalone or replicated) with optional Sentinel for HA. -/// -/// Unlike PostgreSQL and MongoDB, Redis doesn't have a "database" concept that -/// needs per-database provisioning — Redis databases are numbered (0-15) and -/// always available. Configuration focuses on infrastructure concerns. -/// -public class RedisCluster -{ - public Guid Id { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public string RedisVersion { get; private set; } = string.Empty; - public int Replicas { get; private set; } - public string StorageSize { get; private set; } = string.Empty; - public bool SentinelEnabled { get; private set; } - public RedisClusterStatus Status { get; private set; } - public string? StatusMessage { get; private set; } - public string? TargetVersion { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - private RedisCluster() { } - - /// - /// Creates a new Redis instance request within an environment. The instance - /// belongs to the environment and physically runs on the specified hosting cluster. - /// It starts in Provisioning state — the reconciliation loop will pick it up and - /// create the Redis CR on the target Kubernetes cluster via the OT-OPERATORS operator. - /// - /// Think of this like filling out a "new Redis cache" form: you specify what you - /// want (version, replicas, storage, sentinel) and the platform deploys it. - /// - public static RedisCluster Create( - Guid environmentId, - Guid clusterId, - string name, - string ns, - string redisVersion, - int replicas, - string storageSize, - bool sentinelEnabled) - { - // Validate all required inputs. A Redis instance without these is undefined. - - if (environmentId == Guid.Empty) - { - throw new ArgumentException("Environment ID is required.", nameof(environmentId)); - } - - if (clusterId == Guid.Empty) - { - throw new ArgumentException("Cluster ID is required.", nameof(clusterId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Cluster name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - if (replicas < 1) - { - throw new ArgumentException("Must have at least 1 replica.", nameof(replicas)); - } - - return new RedisCluster - { - Id = Guid.NewGuid(), - ClusterId = clusterId, - EnvironmentId = environmentId, - Name = name, - Namespace = ns, - RedisVersion = redisVersion, - Replicas = replicas, - StorageSize = storageSize, - SentinelEnabled = sentinelEnabled, - Status = RedisClusterStatus.Provisioning, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - /// - /// Adopts an existing Redis instance that's already running on the Kubernetes - /// cluster. Unlike Create, the instance starts in Running state because we're - /// importing something that already exists — not deploying from scratch. - /// - public static RedisCluster Adopt( - Guid environmentId, - Guid clusterId, - string name, - string ns, - string redisVersion, - int replicas, - string storageSize, - bool sentinelEnabled) - { - RedisCluster cluster = Create( - environmentId, clusterId, name, ns, redisVersion, replicas, storageSize, sentinelEnabled); - - // Override the status since this instance is already running. - - cluster.Status = RedisClusterStatus.Running; - cluster.LastReconcileAt = DateTimeOffset.UtcNow; - - return cluster; - } - - /// - /// The reconciler confirmed the Redis instance is ready and accepting connections. - /// - public void MarkRunning() - { - Status = RedisClusterStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// The reconciler detected a problem — pods not ready, replication broken, etc. - /// - public void MarkDegraded(string reason) - { - Status = RedisClusterStatus.Degraded; - StatusMessage = reason; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Initiates a version upgrade. The operator handles the rolling update. - /// - /// Rules: - /// - Instance must be Running (can't upgrade a degraded or provisioning instance) - /// - Can't upgrade to the same version - /// - public Result RequestUpgrade(string targetVersion) - { - if (Status != RedisClusterStatus.Running) - { - return Result.Failure($"Cannot upgrade: instance must be running (current: {Status})."); - } - - if (targetVersion == RedisVersion) - { - return Result.Failure($"Instance is already at the same version ({RedisVersion})."); - } - - Status = RedisClusterStatus.Upgrading; - TargetVersion = targetVersion; - - return Result.Success($"Upgrade to Redis {targetVersion} initiated."); - } - - /// - /// The reconciler confirmed the upgrade completed. Version is updated and - /// instance transitions back to Running. - /// - public void CompleteUpgrade() - { - if (TargetVersion is not null) - { - RedisVersion = TargetVersion; - } - - TargetVersion = null; - Status = RedisClusterStatus.Running; - StatusMessage = null; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// Scales the replica count. More replicas = more read capacity and redundancy. - /// - /// Rules: - /// - Must have at least 1 replica - /// - public Result ScaleReplicas(int newCount) - { - if (newCount < 1) - { - return Result.Failure("Must have at least 1 replica."); - } - - int previousCount = Replicas; - Replicas = newCount; - - return Result.Success($"Scaled from {previousCount} to {newCount} replicas."); - } - - /// - /// Enables or disables Redis Sentinel for automatic HA failover. - /// When sentinel is enabled, the operator deploys sentinel pods alongside - /// the Redis instance for monitoring and automatic failover. - /// - public void ConfigureSentinel(bool enabled) - { - SentinelEnabled = enabled; - } -} - -public enum RedisClusterStatus -{ - Provisioning, - Running, - Degraded, - Upgrading, - Decommissioned -} diff --git a/src/EntKube.Provisioning/Domain/ServiceInstance.cs b/src/EntKube.Provisioning/Domain/ServiceInstance.cs deleted file mode 100644 index 9caf610..0000000 --- a/src/EntKube.Provisioning/Domain/ServiceInstance.cs +++ /dev/null @@ -1,112 +0,0 @@ -namespace EntKube.Provisioning.Domain; - -/// -/// A ServiceInstance represents a shared Kubernetes application (MinIO, CloudNativePG, -/// Keycloak, Gitea, Harbor, etc.) that belongs to an environment. The service physically -/// runs on one of the clusters within that environment, but its lifecycle is managed -/// at the environment level. It tracks the service type, desired state, current state, -/// and which cluster hosts it. -/// This is the aggregate root for provisioning operations. -/// -public class ServiceInstance -{ - public Guid Id { get; private set; } - public Guid EnvironmentId { get; private set; } - public Guid ClusterId { get; private set; } - public ServiceType ServiceType { get; private set; } - public string Name { get; private set; } = string.Empty; - public string Namespace { get; private set; } = string.Empty; - public ServiceState DesiredState { get; private set; } - public ServiceState CurrentState { get; private set; } - public DateTimeOffset CreatedAt { get; private set; } - public DateTimeOffset? LastReconcileAt { get; private set; } - - private ServiceInstance() { } - - /// - /// Requests provisioning of a new shared service within an environment. - /// The service belongs to the environment and physically runs on the specified - /// hosting cluster. It starts in a Pending state — the reconciliation loop will - /// pick it up and deploy the necessary Helm chart or Kubernetes resources. - /// - public static ServiceInstance Provision(Guid environmentId, ServiceType serviceType, string name, string ns, Guid clusterId) - { - if (environmentId == Guid.Empty) - { - throw new ArgumentException("Environment ID is required.", nameof(environmentId)); - } - - if (clusterId == Guid.Empty) - { - throw new ArgumentException("Cluster ID is required.", nameof(clusterId)); - } - - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("Service name is required.", nameof(name)); - } - - if (string.IsNullOrWhiteSpace(ns)) - { - throw new ArgumentException("Namespace is required.", nameof(ns)); - } - - return new ServiceInstance - { - Id = Guid.NewGuid(), - EnvironmentId = environmentId, - ClusterId = clusterId, - ServiceType = serviceType, - Name = name, - Namespace = ns, - DesiredState = ServiceState.Running, - CurrentState = ServiceState.Pending, - CreatedAt = DateTimeOffset.UtcNow - }; - } - - /// - /// The reconciliation loop calls this after successfully deploying or verifying - /// the service is running on the cluster. - /// - public void MarkRunning() - { - CurrentState = ServiceState.Running; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// When the reconciliation loop detects the service is degraded or unreachable. - /// - public void MarkDegraded() - { - CurrentState = ServiceState.Degraded; - LastReconcileAt = DateTimeOffset.UtcNow; - } - - /// - /// A tenant requests decommissioning of the service. We set the desired state - /// to Decommissioned and the reconciliation loop will handle teardown. - /// - public void RequestDecommission() - { - DesiredState = ServiceState.Decommissioned; - } -} - -public enum ServiceType -{ - MinIO, - CloudNativePG, - Keycloak, - Gitea, - Harbor -} - -public enum ServiceState -{ - Pending, - Running, - Degraded, - Decommissioned -} diff --git a/src/EntKube.Provisioning/EntKube.Provisioning.csproj b/src/EntKube.Provisioning/EntKube.Provisioning.csproj deleted file mode 100644 index e4f46c5..0000000 --- a/src/EntKube.Provisioning/EntKube.Provisioning.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - diff --git a/src/EntKube.Provisioning/EntKube.Provisioning.http b/src/EntKube.Provisioning/EntKube.Provisioning.http deleted file mode 100644 index 410ceb6..0000000 --- a/src/EntKube.Provisioning/EntKube.Provisioning.http +++ /dev/null @@ -1,6 +0,0 @@ -@EntKube.Provisioning_HostAddress = http://localhost:5260 - -GET {{EntKube.Provisioning_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/EntKube.Provisioning/Features/Apps/AddAppEnvironment/AddAppEnvironmentHandler.cs b/src/EntKube.Provisioning/Features/Apps/AddAppEnvironment/AddAppEnvironmentHandler.cs deleted file mode 100644 index 15bfb27..0000000 --- a/src/EntKube.Provisioning/Features/Apps/AddAppEnvironment/AddAppEnvironmentHandler.cs +++ /dev/null @@ -1,66 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.Reconcile; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.AddAppEnvironment; - -/// -/// Handles requests to add an app to a new environment. The admin selects -/// which environment and cluster to target, and provides a Kubernetes -/// namespace. After saving the environment, we create the namespace on -/// the cluster so it's ready for deployments. -/// -public class AddAppEnvironmentHandler -{ - private readonly IAppRepository repository; - private readonly IKubernetesApplyClient applyClient; - - public AddAppEnvironmentHandler(IAppRepository repository, IKubernetesApplyClient applyClient) - { - this.repository = repository; - this.applyClient = applyClient; - } - - public async Task> HandleAsync(AddAppEnvironmentRequest request, CancellationToken ct = default) - { - // Find the app — can't add an environment to an app that doesn't exist. - - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure("App not found."); - } - - // The domain validates uniqueness and required fields. - // We catch domain exceptions and translate them to Result failures. - - try - { - AppEnvironment env = app.AddEnvironment(request.EnvironmentId, request.ClusterId, request.Namespace); - await repository.UpdateAsync(app, ct); - - // Create the namespace on the cluster so there's somewhere to deploy to. - // This is idempotent — if the namespace already exists, it's a no-op. - - string nsYaml = ManifestGenerator.GenerateNamespace(request.Namespace); - await applyClient.ApplyManifestAsync(request.ClusterId, nsYaml, ct); - - return Result.Success(env.Id); - } - catch (InvalidOperationException ex) - { - return Result.Failure(ex.Message); - } - catch (ArgumentException ex) - { - return Result.Failure(ex.Message); - } - } -} - -public record AddAppEnvironmentRequest( - Guid AppId, - Guid EnvironmentId, - Guid ClusterId, - string Namespace); diff --git a/src/EntKube.Provisioning/Features/Apps/AddAppSecret/AddAppSecretHandler.cs b/src/EntKube.Provisioning/Features/Apps/AddAppSecret/AddAppSecretHandler.cs deleted file mode 100644 index 424fb24..0000000 --- a/src/EntKube.Provisioning/Features/Apps/AddAppSecret/AddAppSecretHandler.cs +++ /dev/null @@ -1,58 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.AddAppSecret; - -/// -/// Handles requests to add a secret reference to an app environment. -/// The actual secret value lives in the vault (scoped to Tenant → Customer -/// → App → Environment). We just store the mapping: Kubernetes Secret key -/// name → vault key. The reconciler resolves the values at sync time. -/// -public class AddAppSecretHandler -{ - private readonly IAppRepository repository; - - public AddAppSecretHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(AddAppSecretRequest request, CancellationToken ct = default) - { - // Find the app and the specific environment. - - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure("App not found."); - } - - AppEnvironment? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == request.EnvironmentId); - - if (env is null) - { - return Result.Failure($"Environment '{request.EnvironmentId}' not found on this app."); - } - - // The domain validates uniqueness of the secret name. - - try - { - env.AddSecret(request.Name, request.VaultKey); - await repository.UpdateAsync(app, ct); - return Result.Success(); - } - catch (InvalidOperationException ex) - { - return Result.Failure(ex.Message); - } - } -} - -public record AddAppSecretRequest( - Guid AppId, - Guid EnvironmentId, - string Name, - string VaultKey); diff --git a/src/EntKube.Provisioning/Features/Apps/AppEndpoints.cs b/src/EntKube.Provisioning/Features/Apps/AppEndpoints.cs deleted file mode 100644 index f7d3930..0000000 --- a/src/EntKube.Provisioning/Features/Apps/AppEndpoints.cs +++ /dev/null @@ -1,451 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.AddAppEnvironment; -using EntKube.Provisioning.Features.Apps.AddAppSecret; -using EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment; -using EntKube.Provisioning.Features.Apps.CreateApp; -using EntKube.Provisioning.Features.Apps.DeleteApp; -using EntKube.Provisioning.Features.Apps.GetApps; -using EntKube.Provisioning.Features.Apps.RemoveAppEnvironment; -using EntKube.Provisioning.Features.Apps.RemoveAppSecret; -using EntKube.Provisioning.Features.Apps.SuspendActivateApp; -using EntKube.Provisioning.Features.Apps.TriggerSync; -using EntKube.Provisioning.Features.Apps.Reconcile; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.Apps; - -/// -/// API endpoints for managing customer apps. Apps are the unit of deployment — -/// each one represents a workload (Deployment or Helm chart) that can be deployed -/// across multiple environments with per-environment configuration and secrets. -/// -public static class AppEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - // ─── App CRUD ──────────────────────────────────────────────────── - - // GET /api/customers/{customerId}/apps — list all apps for a customer. - - app.MapGet("/api/customers/{customerId:guid}/apps", async ( - Guid customerId, - [FromServices] GetAppsHandler handler, - CancellationToken ct) => - { - Result> result = await handler.HandleByCustomerAsync(customerId, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - List summaries = result.Value!.Select(a => new AppSummary( - a.Id, a.CustomerId, a.TenantId, a.Name, a.Slug, - a.Type.ToString(), a.Status.ToString(), - a.Environments.Count, a.CreatedAt)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - - // GET /api/apps/{appId} — get full app details including environments. - - app.MapGet("/api/apps/{appId:guid}", async ( - Guid appId, - [FromServices] GetAppsHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleByIdAsync(appId, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - App a = result.Value!; - - AppDetail detail = new( - a.Id, a.CustomerId, a.TenantId, a.Name, a.Slug, - a.Type.ToString(), a.Status.ToString(), a.CreatedAt, - a.Environments.Select(e => new AppEnvironmentSummary( - e.Id, e.EnvironmentId, e.ClusterId, e.Namespace, - e.DeploymentSpec, e.HelmReleaseSpec, - e.Secrets.ToList(), - e.SyncStatus.ToString(), e.SyncStatusMessage, e.LastSyncAt)).ToList()); - - return Results.Ok(ApiResponse.Ok(detail)); - }); - - // POST /api/customers/{customerId}/apps — create a new app. - - app.MapPost("/api/customers/{customerId:guid}/apps", async ( - Guid customerId, - [FromBody] CreateAppApiRequest request, - [FromServices] CreateAppHandler handler, - CancellationToken ct) => - { - CreateAppRequest handlerRequest = new( - request.TenantId, customerId, request.Name, request.Slug, request.Type); - - Result result = await handler.HandleAsync(handlerRequest, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created( - $"/api/apps/{result.Value}", - ApiResponse.Ok(result.Value!)); - }); - - // ─── Environment Management ────────────────────────────────────── - - // POST /api/apps/{appId}/environments — add the app to an environment. - - app.MapPost("/api/apps/{appId:guid}/environments", async ( - Guid appId, - [FromBody] AddEnvironmentApiRequest request, - [FromServices] AddAppEnvironmentHandler handler, - CancellationToken ct) => - { - AddAppEnvironmentRequest handlerRequest = new( - appId, request.EnvironmentId, request.ClusterId, request.Namespace); - - Result result = await handler.HandleAsync(handlerRequest, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // PUT /api/apps/{appId}/environments/{environmentId}/deployment — configure deployment spec. - - app.MapPut("/api/apps/{appId:guid}/environments/{environmentId:guid}/deployment", async ( - Guid appId, - Guid environmentId, - [FromBody] DeploymentSpec spec, - [FromServices] ConfigureAppEnvironmentHandler handler, - CancellationToken ct) => - { - ConfigureDeploymentRequest request = new(appId, environmentId, spec); - Result result = await handler.HandleDeploymentAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // PUT /api/apps/{appId}/environments/{environmentId}/helm — configure helm release spec. - - app.MapPut("/api/apps/{appId:guid}/environments/{environmentId:guid}/helm", async ( - Guid appId, - Guid environmentId, - [FromBody] HelmReleaseSpec spec, - [FromServices] ConfigureAppEnvironmentHandler handler, - CancellationToken ct) => - { - ConfigureHelmReleaseRequest request = new(appId, environmentId, spec); - Result result = await handler.HandleHelmReleaseAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // ─── Secrets Management ────────────────────────────────────────── - - // POST /api/apps/{appId}/environments/{environmentId}/secrets — add a secret reference. - - app.MapPost("/api/apps/{appId:guid}/environments/{environmentId:guid}/secrets", async ( - Guid appId, - Guid environmentId, - [FromBody] AddSecretApiRequest request, - [FromServices] AddAppSecretHandler handler, - CancellationToken ct) => - { - AddAppSecretRequest handlerRequest = new(appId, environmentId, request.Name, request.VaultKey); - Result result = await handler.HandleAsync(handlerRequest, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // ─── Delete / Remove ───────────────────────────────────────────── - - // DELETE /api/apps/{appId} — delete an app and all its environments. - - app.MapDelete("/api/apps/{appId:guid}", async ( - Guid appId, - [FromServices] DeleteAppHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(appId, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // DELETE /api/apps/{appId}/environments/{environmentId} — remove an environment from an app. - - app.MapDelete("/api/apps/{appId:guid}/environments/{environmentId:guid}", async ( - Guid appId, - Guid environmentId, - [FromServices] RemoveAppEnvironmentHandler handler, - CancellationToken ct) => - { - RemoveAppEnvironmentRequest request = new(appId, environmentId); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // DELETE /api/apps/{appId}/environments/{environmentId}/secrets/{secretName} — remove a secret. - - app.MapDelete("/api/apps/{appId:guid}/environments/{environmentId:guid}/secrets/{secretName}", async ( - Guid appId, - Guid environmentId, - string secretName, - [FromServices] RemoveAppSecretHandler handler, - CancellationToken ct) => - { - RemoveAppSecretRequest request = new(appId, environmentId, secretName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // ─── App Actions ───────────────────────────────────────────────── - - // POST /api/apps/{appId}/suspend — suspend an app (stops reconciliation). - - app.MapPost("/api/apps/{appId:guid}/suspend", async ( - Guid appId, - [FromServices] SuspendActivateAppHandler handler, - CancellationToken ct) => - { - Result result = await handler.SuspendAsync(appId, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // POST /api/apps/{appId}/activate — re-activate a suspended app. - - app.MapPost("/api/apps/{appId:guid}/activate", async ( - Guid appId, - [FromServices] SuspendActivateAppHandler handler, - CancellationToken ct) => - { - Result result = await handler.ActivateAsync(appId, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // POST /api/apps/{appId}/environments/{environmentId}/sync — trigger immediate sync. - - app.MapPost("/api/apps/{appId:guid}/environments/{environmentId:guid}/sync", async ( - Guid appId, - Guid environmentId, - [FromServices] TriggerSyncHandler handler, - CancellationToken ct) => - { - TriggerSyncRequest request = new(appId, environmentId); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(null!)); - }); - - // ─── Cluster Resource Lookups ──────────────────────────────────── - - // GET /api/clusters/{clusterId}/namespaces/{ns}/service-accounts — list service accounts. - - app.MapGet("/api/clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async ( - Guid clusterId, - string ns, - [FromServices] IKubernetesApplyClient applyClient, - CancellationToken ct) => - { - try - { - List serviceAccounts = await applyClient.ListServiceAccountsAsync(clusterId, ns, ct); - return Results.Ok(ApiResponse>.Ok(serviceAccounts)); - } - catch (Exception ex) - { - return Results.Json( - ApiResponse.Fail($"Failed to list service accounts: {ex.Message}"), - statusCode: 502); - } - }); - - // GET /api/clusters/{clusterId}/namespaces/{ns}/pvcs — list PVCs. - - app.MapGet("/api/clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async ( - Guid clusterId, - string ns, - [FromServices] IKubernetesApplyClient applyClient, - CancellationToken ct) => - { - try - { - List pvcs = await applyClient.ListPvcsAsync(clusterId, ns, ct); - return Results.Ok(ApiResponse>.Ok(pvcs)); - } - catch (Exception ex) - { - return Results.Json( - ApiResponse.Fail($"Failed to list PVCs: {ex.Message}"), - statusCode: 502); - } - }); - - // POST /api/clusters/{clusterId}/namespaces/{ns}/service-accounts — create a service account. - - app.MapPost("/api/clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async ( - Guid clusterId, - string ns, - [FromBody] CreateResourceApiRequest request, - [FromServices] IKubernetesApplyClient applyClient, - CancellationToken ct) => - { - try - { - string yaml = ManifestGenerator.GenerateServiceAccount(request.Name, ns); - await applyClient.ApplyManifestAsync(clusterId, yaml, ct); - return Results.Ok(ApiResponse.Ok(request.Name)); - } - catch (Exception ex) - { - return Results.Json( - ApiResponse.Fail($"Failed to create service account: {ex.Message}"), - statusCode: 502); - } - }); - - // POST /api/clusters/{clusterId}/namespaces/{ns}/pvcs — create a PVC. - - app.MapPost("/api/clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async ( - Guid clusterId, - string ns, - [FromBody] CreatePvcApiRequest request, - [FromServices] IKubernetesApplyClient applyClient, - CancellationToken ct) => - { - try - { - string yaml = ManifestGenerator.GeneratePersistentVolumeClaim( - request.Name, ns, request.StorageSize, request.AccessMode, request.StorageClass); - await applyClient.ApplyManifestAsync(clusterId, yaml, ct); - return Results.Ok(ApiResponse.Ok(request.Name)); - } - catch (Exception ex) - { - return Results.Json( - ApiResponse.Fail($"Failed to create PVC: {ex.Message}"), - statusCode: 502); - } - }); - } -} - -// ─── API DTOs ──────────────────────────────────────────────────────────── - -public record CreateAppApiRequest( - Guid TenantId, - string Name, - string Slug, - AppType Type); - -public record AddEnvironmentApiRequest( - Guid EnvironmentId, - Guid ClusterId, - string Namespace); - -public record AddSecretApiRequest( - string Name, - string VaultKey); - -public record AppSummary( - Guid Id, - Guid CustomerId, - Guid TenantId, - string Name, - string Slug, - string Type, - string Status, - int EnvironmentCount, - DateTimeOffset CreatedAt); - -public record AppDetail( - Guid Id, - Guid CustomerId, - Guid TenantId, - string Name, - string Slug, - string Type, - string Status, - DateTimeOffset CreatedAt, - List Environments); - -public record AppEnvironmentSummary( - Guid Id, - Guid EnvironmentId, - Guid ClusterId, - string Namespace, - DeploymentSpec? DeploymentSpec, - HelmReleaseSpec? HelmReleaseSpec, - List Secrets, - string SyncStatus, - string? SyncStatusMessage, - DateTimeOffset? LastSyncAt); - -public record CreateResourceApiRequest(string Name); - -public record CreatePvcApiRequest( - string Name, - string StorageSize, - string AccessMode = "ReadWriteOnce", - string? StorageClass = null); diff --git a/src/EntKube.Provisioning/Features/Apps/ConfigureAppEnvironment/ConfigureAppEnvironmentHandler.cs b/src/EntKube.Provisioning/Features/Apps/ConfigureAppEnvironment/ConfigureAppEnvironmentHandler.cs deleted file mode 100644 index 5f4541b..0000000 --- a/src/EntKube.Provisioning/Features/Apps/ConfigureAppEnvironment/ConfigureAppEnvironmentHandler.cs +++ /dev/null @@ -1,91 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment; - -/// -/// Handles requests to configure an app environment's deployment spec (for -/// Deployment-type apps) or helm release spec (for HelmChart-type apps). -/// This is the step where the admin defines exactly what gets deployed — -/// container image, replicas, ports, routing, or helm chart details. -/// -public class ConfigureAppEnvironmentHandler -{ - private readonly IAppRepository repository; - - public ConfigureAppEnvironmentHandler(IAppRepository repository) - { - this.repository = repository; - } - - /// - /// Configures a Deployment-type app's environment with container, service, - /// and routing specifications. The reconciler will generate deployment.yaml, - /// service.yaml, and httproute.yaml from this. - /// - public async Task HandleDeploymentAsync(ConfigureDeploymentRequest request, CancellationToken ct = default) - { - // Find the app and the specific environment within it. - - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure("App not found."); - } - - AppEnvironment? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == request.EnvironmentId); - - if (env is null) - { - return Result.Failure($"Environment '{request.EnvironmentId}' not found on this app."); - } - - // Apply the deployment configuration. This resets sync status to Pending. - - env.ConfigureDeployment(request.Spec); - await repository.UpdateAsync(app, ct); - - return Result.Success(); - } - - /// - /// Configures a HelmChart-type app's environment with chart repo, version, - /// and values. The reconciler will run `helm upgrade --install` from this. - /// - public async Task HandleHelmReleaseAsync(ConfigureHelmReleaseRequest request, CancellationToken ct = default) - { - // Find the app and the specific environment within it. - - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure("App not found."); - } - - AppEnvironment? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == request.EnvironmentId); - - if (env is null) - { - return Result.Failure($"Environment '{request.EnvironmentId}' not found on this app."); - } - - // Apply the helm release configuration. This resets sync status to Pending. - - env.ConfigureHelmRelease(request.Spec); - await repository.UpdateAsync(app, ct); - - return Result.Success(); - } -} - -public record ConfigureDeploymentRequest( - Guid AppId, - Guid EnvironmentId, - DeploymentSpec Spec); - -public record ConfigureHelmReleaseRequest( - Guid AppId, - Guid EnvironmentId, - HelmReleaseSpec Spec); diff --git a/src/EntKube.Provisioning/Features/Apps/CreateApp/CreateAppHandler.cs b/src/EntKube.Provisioning/Features/Apps/CreateApp/CreateAppHandler.cs deleted file mode 100644 index cca1d85..0000000 --- a/src/EntKube.Provisioning/Features/Apps/CreateApp/CreateAppHandler.cs +++ /dev/null @@ -1,57 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.CreateApp; - -/// -/// Handles requests to create a new app for a customer. The customer admin -/// provides a name, slug, and deployment type (raw manifests or Helm chart). -/// The app is persisted and ready to have environments added. -/// -public class CreateAppHandler -{ - private readonly IAppRepository repository; - - public CreateAppHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task> HandleAsync(CreateAppRequest request, CancellationToken ct = default) - { - // Validate required fields before we touch the domain. - - if (string.IsNullOrWhiteSpace(request.Name)) - { - return Result.Failure("App name is required."); - } - - if (string.IsNullOrWhiteSpace(request.Slug)) - { - return Result.Failure("App slug is required."); - } - - // Check that the slug isn't already taken within this customer. - - App? existing = await repository.GetByCustomerAndSlugAsync(request.CustomerId, request.Slug, ct); - - if (existing is not null) - { - return Result.Failure($"An app with slug '{request.Slug}' already exists for this customer."); - } - - // Create the app aggregate. The domain enforces any remaining invariants. - - App app = App.Create(request.TenantId, request.CustomerId, request.Name, request.Slug, request.Type); - await repository.AddAsync(app, ct); - - return Result.Success(app.Id); - } -} - -public record CreateAppRequest( - Guid TenantId, - Guid CustomerId, - string Name, - string Slug, - AppType Type); diff --git a/src/EntKube.Provisioning/Features/Apps/DeleteApp/DeleteAppHandler.cs b/src/EntKube.Provisioning/Features/Apps/DeleteApp/DeleteAppHandler.cs deleted file mode 100644 index 2744553..0000000 --- a/src/EntKube.Provisioning/Features/Apps/DeleteApp/DeleteAppHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.DeleteApp; - -/// -/// Handles deleting an app and all its environments. The caller confirms the -/// intent — once deleted, the app is removed from the database. The reconciler -/// does NOT clean up Kubernetes resources automatically; that's a future -/// enhancement (a "teardown" reconciliation pass). -/// -public class DeleteAppHandler -{ - private readonly IAppRepository repository; - - public DeleteAppHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(Guid appId, CancellationToken ct = default) - { - // Look up the app. If it doesn't exist, return a failure. - - App? app = await repository.GetByIdAsync(appId, ct); - - if (app is null) - { - return Result.Failure($"App with ID '{appId}' was not found."); - } - - // Delete the app and all its child environments from the database. - - await repository.DeleteAsync(appId, ct); - - return Result.Success(); - } -} diff --git a/src/EntKube.Provisioning/Features/Apps/GetApps/GetAppsHandler.cs b/src/EntKube.Provisioning/Features/Apps/GetApps/GetAppsHandler.cs deleted file mode 100644 index d93e677..0000000 --- a/src/EntKube.Provisioning/Features/Apps/GetApps/GetAppsHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.GetApps; - -/// -/// Retrieves all apps for a given customer. The BFF calls this when the user -/// opens the Apps tab on the customer detail page. -/// -public class GetAppsHandler -{ - private readonly IAppRepository repository; - - public GetAppsHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task>> HandleByCustomerAsync(Guid customerId, CancellationToken ct = default) - { - if (customerId == Guid.Empty) - { - return Result.Failure>("Customer ID is required."); - } - - IReadOnlyList apps = await repository.GetByCustomerIdAsync(customerId, ct); - return Result.Success(apps); - } - - public async Task> HandleByIdAsync(Guid appId, CancellationToken ct = default) - { - if (appId == Guid.Empty) - { - return Result.Failure("App ID is required."); - } - - App? app = await repository.GetByIdAsync(appId, ct); - - if (app is null) - { - return Result.Failure("App not found."); - } - - return Result.Success(app); - } -} diff --git a/src/EntKube.Provisioning/Features/Apps/Reconcile/AppReconcileHandler.cs b/src/EntKube.Provisioning/Features/Apps/Reconcile/AppReconcileHandler.cs deleted file mode 100644 index 2465ee3..0000000 --- a/src/EntKube.Provisioning/Features/Apps/Reconcile/AppReconcileHandler.cs +++ /dev/null @@ -1,270 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// The reconcile handler is the engine that syncs desired app state to Kubernetes. -/// It iterates over all active apps, finds environments that need syncing (Pending -/// or OutOfSync), generates the appropriate manifests or helm commands, and applies -/// them to the target cluster. -/// -/// This is the core of the "ArgoCD without Git" approach: -/// 1. For Deployment-type apps → generate and apply deployment.yaml, service.yaml, -/// httproute.yaml, and secrets.yaml -/// 2. For HelmChart-type apps → run helm upgrade --install with the stored values -/// 3. In both cases → resolve secrets from the vault and create a K8s Secret resource -/// -/// The handler is designed to be called by a BackgroundService on a timer (e.g., -/// every 30 seconds) or triggered on-demand after a configuration change. -/// -public class AppReconcileHandler -{ - private readonly IAppRepository repository; - private readonly IKubernetesApplyClient applyClient; - private readonly ISecretResolver secretResolver; - private readonly IClusterGatewayResolver gatewayResolver; - - public AppReconcileHandler( - IAppRepository repository, - IKubernetesApplyClient applyClient, - ISecretResolver secretResolver, - IClusterGatewayResolver gatewayResolver) - { - this.repository = repository; - this.applyClient = applyClient; - this.secretResolver = secretResolver; - this.gatewayResolver = gatewayResolver; - } - - /// - /// Reconciles all active apps. Each app's environments are checked — - /// only Pending or OutOfSync environments are processed. Already-synced - /// environments are skipped. Errors on one environment don't block others. - /// - public async Task ReconcileAsync(CancellationToken ct) - { - // Load all active apps with their environments. - - IReadOnlyList apps = await repository.GetAllActiveAsync(ct); - - foreach (App app in apps) - { - // Skip suspended apps — their environments stay in whatever state - // they were in when suspended. - - if (app.Status != AppStatus.Active) - { - continue; - } - - foreach (AppEnvironment env in app.Environments) - { - // Only reconcile environments that need work — Pending (new config) - // or OutOfSync (drift detected). Synced and Error environments are - // left alone until their config changes or an operator triggers retry. - - if (env.SyncStatus != AppSyncStatus.Pending && env.SyncStatus != AppSyncStatus.OutOfSync) - { - continue; - } - - await ReconcileEnvironmentAsync(app, env, ct); - await repository.UpdateAsync(app, ct); - } - } - } - - /// - /// Reconciles a single app environment. Dispatches to the appropriate - /// strategy based on app type (Deployment manifests vs Helm chart). - /// - private async Task ReconcileEnvironmentAsync(App app, AppEnvironment env, CancellationToken ct) - { - try - { - switch (app.Type) - { - case AppType.Deployment: - await ReconcileDeploymentAsync(app, env, ct); - break; - - case AppType.HelmChart: - await ReconcileHelmChartAsync(app, env, ct); - break; - } - - // Everything applied successfully — mark the environment as synced. - - env.MarkSynced(); - } - catch (Exception ex) - { - // Something went wrong — mark the environment with the error message - // so the admin can see what happened in the UI. - - env.MarkError(ex.Message); - } - } - - /// - /// Reconciles a Deployment-type app. Generates and applies: - /// 1. Secrets manifest (if any secrets are configured) - /// 2. Deployment manifest (with secretKeyRef for secrets) - /// 3. Service manifest - /// 4. HTTPRoute manifest (if hostname is configured) - /// - private async Task ReconcileDeploymentAsync(App app, AppEnvironment env, CancellationToken ct) - { - if (env.DeploymentSpec is null) - { - return; - } - - // Step 1: Resolve secrets from the vault and generate the Secret manifest. - // We do this first so the Secret exists before the Deployment references it. - - Dictionary resolvedSecrets = new(); - - if (env.Secrets.Count > 0) - { - foreach (AppSecret secret in env.Secrets) - { - string? value = await secretResolver.ResolveAsync( - app.TenantId, app.CustomerId, app.Slug, env.EnvironmentId, - secret.VaultKey, ct); - - if (value is not null) - { - resolvedSecrets[secret.Name] = value; - } - } - - string secretYaml = ManifestGenerator.GenerateSecret(app.Slug, env.Namespace, resolvedSecrets); - - if (!string.IsNullOrEmpty(secretYaml)) - { - await applyClient.ApplyManifestAsync(env.ClusterId, secretYaml, ct); - } - } - - // Step 2: Generate and apply the Deployment manifest. - // If secrets exist, the deployment includes secretKeyRef env vars. - - List? secretRefs = env.Secrets.Count > 0 ? env.Secrets.ToList() : null; - string deploymentYaml = ManifestGenerator.GenerateDeployment( - app.Slug, env.Namespace, env.DeploymentSpec, secretRefs); - - await applyClient.ApplyManifestAsync(env.ClusterId, deploymentYaml, ct); - - // Step 3: Generate and apply Service manifests. - // If the spec has a Services list, generate one Service per entry. - // Otherwise fall back to the legacy single-service path using - // ServicePort/ContainerPort from DeploymentSpec. - - if (env.DeploymentSpec.Services is { Count: > 0 }) - { - foreach (ServiceSpec svc in env.DeploymentSpec.Services) - { - string svcYaml = ManifestGenerator.GenerateService( - app.Slug, env.Namespace, svc); - - await applyClient.ApplyManifestAsync(env.ClusterId, svcYaml, ct); - } - } - else - { - string serviceYaml = ManifestGenerator.GenerateService( - app.Slug, env.Namespace, env.DeploymentSpec); - - await applyClient.ApplyManifestAsync(env.ClusterId, serviceYaml, ct); - } - - // Step 4: Generate and apply route manifests. - // If the spec has a Routes list, generate one route per entry using - // the new GenerateRoute method that supports HTTP, TLS, TCP, UDP, gRPC. - // Routes that don't have an explicit GatewayRef get the cluster's default - // gateway injected automatically — users never need to enter this manually. - // Otherwise fall back to the legacy HTTPRoute path using HostName/PathPrefix. - - if (env.DeploymentSpec.Routes is { Count: > 0 }) - { - // Resolve the cluster's default gateway once for all routes in this - // environment. This avoids redundant HTTP calls to the Clusters service. - - ResolvedGateway? defaultGateway = await gatewayResolver.GetDefaultGatewayAsync(env.ClusterId, ct); - - foreach (RouteSpec route in env.DeploymentSpec.Routes) - { - // If the route doesn't specify a gateway, inject the cluster's - // default. This is the key change — users only provide hostnames - // and paths, and the system figures out the gateway. - - RouteSpec routeWithGateway = route.GatewayRef is null && defaultGateway?.GatewayName is not null - ? route with { GatewayRef = new GatewayReference(defaultGateway.GatewayName, defaultGateway.GatewayNamespace) } - : route; - - string routeYaml = ManifestGenerator.GenerateRoute( - app.Slug, env.Namespace, routeWithGateway); - - if (!string.IsNullOrEmpty(routeYaml)) - { - await applyClient.ApplyManifestAsync(env.ClusterId, routeYaml, ct); - } - } - } - else - { - string httpRouteYaml = ManifestGenerator.GenerateHttpRoute( - app.Slug, env.Namespace, env.DeploymentSpec); - - if (!string.IsNullOrEmpty(httpRouteYaml)) - { - await applyClient.ApplyManifestAsync(env.ClusterId, httpRouteYaml, ct); - } - } - } - - /// - /// Reconciles a HelmChart-type app. Calls helm upgrade --install with - /// the stored chart details and values. If secrets are configured, they - /// are deployed as a companion K8s Secret before the helm install. - /// - private async Task ReconcileHelmChartAsync(App app, AppEnvironment env, CancellationToken ct) - { - if (env.HelmReleaseSpec is null) - { - return; - } - - // If the helm app also has secrets, resolve them and create the Secret resource. - - if (env.Secrets.Count > 0) - { - Dictionary resolvedSecrets = new(); - - foreach (AppSecret secret in env.Secrets) - { - string? value = await secretResolver.ResolveAsync( - app.TenantId, app.CustomerId, app.Slug, env.EnvironmentId, - secret.VaultKey, ct); - - if (value is not null) - { - resolvedSecrets[secret.Name] = value; - } - } - - string secretYaml = ManifestGenerator.GenerateSecret(app.Slug, env.Namespace, resolvedSecrets); - - if (!string.IsNullOrEmpty(secretYaml)) - { - await applyClient.ApplyManifestAsync(env.ClusterId, secretYaml, ct); - } - } - - // Run helm upgrade --install with the stored chart and values. - - await applyClient.HelmUpgradeInstallAsync( - env.ClusterId, app.Slug, env.Namespace, env.HelmReleaseSpec, ct); - } -} diff --git a/src/EntKube.Provisioning/Features/Apps/Reconcile/AppReconcilerService.cs b/src/EntKube.Provisioning/Features/Apps/Reconcile/AppReconcilerService.cs deleted file mode 100644 index cb4d2bf..0000000 --- a/src/EntKube.Provisioning/Features/Apps/Reconcile/AppReconcilerService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// A background service that periodically reconciles app environments. -/// Every 30 seconds it wakes up, creates a new scope (to get fresh DbContext -/// and dependencies), and runs the reconcile handler. This is the heartbeat -/// that keeps Kubernetes in sync with the desired state stored in the database. -/// -/// If a reconciliation cycle fails unexpectedly, the error is logged and the -/// service keeps running — it will try again on the next tick. -/// -public class AppReconcilerService : BackgroundService -{ - private readonly IServiceProvider serviceProvider; - private readonly ILogger logger; - private readonly TimeSpan interval = TimeSpan.FromSeconds(30); - - public AppReconcilerService( - IServiceProvider serviceProvider, - ILogger logger) - { - this.serviceProvider = serviceProvider; - this.logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("App reconciler started. Polling every {Interval}s.", interval.TotalSeconds); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - // Each cycle gets its own scope so the DbContext and repository - // are fresh — no stale tracking entities across cycles. - - using (IServiceScope scope = serviceProvider.CreateScope()) - { - AppReconcileHandler handler = scope.ServiceProvider.GetRequiredService(); - await handler.ReconcileAsync(stoppingToken); - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - // Graceful shutdown — exit the loop. - - break; - } - catch (Exception ex) - { - // Log the error but don't crash. The reconciler will try again - // on the next tick. - - logger.LogError(ex, "App reconciliation cycle failed."); - } - - await Task.Delay(interval, stoppingToken); - } - - logger.LogInformation("App reconciler stopped."); - } -} diff --git a/src/EntKube.Provisioning/Features/Apps/Reconcile/IClusterGatewayResolver.cs b/src/EntKube.Provisioning/Features/Apps/Reconcile/IClusterGatewayResolver.cs deleted file mode 100644 index 105189a..0000000 --- a/src/EntKube.Provisioning/Features/Apps/Reconcile/IClusterGatewayResolver.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// Resolves the default ingress gateway for a cluster. When an app route -/// doesn't specify which gateway to attach to, the reconciler asks this -/// service to look up the cluster's ingress configuration and return the -/// appropriate gateway reference. -/// -/// The actual gateway info comes from the Clusters service, which discovers -/// it during adoption scans (Istio gateways, Traefik, etc.). -/// -public interface IClusterGatewayResolver -{ - /// - /// Returns the default gateway reference for the given cluster, or null - /// if no ingress gateway is configured (e.g., Traefik manages its own - /// ingress, or no ingress provider is installed at all). - /// - Task GetDefaultGatewayAsync(Guid clusterId, CancellationToken ct = default); -} - -/// -/// The resolved gateway info from the cluster's ingress component. -/// Provider tells the caller how routes should be created (Gateway API vs IngressRoute). -/// GatewayName and GatewayNamespace are the parentRef for Gateway API routes. -/// -public record ResolvedGateway( - string Provider, - string? GatewayName, - string? GatewayNamespace); diff --git a/src/EntKube.Provisioning/Features/Apps/Reconcile/IKubernetesApplyClient.cs b/src/EntKube.Provisioning/Features/Apps/Reconcile/IKubernetesApplyClient.cs deleted file mode 100644 index a80832b..0000000 --- a/src/EntKube.Provisioning/Features/Apps/Reconcile/IKubernetesApplyClient.cs +++ /dev/null @@ -1,47 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// Abstracts the Kubernetes cluster connection for applying manifests. -/// The reconciler calls this to push generated YAML to the target cluster. -/// In production, this uses the K8s client with kubeconfig fetched from -/// the Clusters service. In tests, it can be replaced with a mock. -/// -public interface IKubernetesApplyClient -{ - /// - /// Applies a YAML manifest to the target cluster. Uses server-side apply - /// semantics (create if missing, update if exists) so the operation is - /// idempotent — running it twice produces the same result. - /// - Task ApplyManifestAsync(Guid clusterId, string yaml, CancellationToken ct = default); - - /// - /// Installs or upgrades a Helm release on the target cluster using the - /// provided chart details and values. Equivalent to `helm upgrade --install`. - /// - Task HelmUpgradeInstallAsync( - Guid clusterId, - string releaseName, - string ns, - HelmReleaseSpec spec, - CancellationToken ct = default); - - /// - /// Lists Kubernetes service accounts in a namespace on the target cluster. - /// Returns a list of service account names. - /// - Task> ListServiceAccountsAsync(Guid clusterId, string ns, CancellationToken ct = default); - - /// - /// Lists PersistentVolumeClaims in a namespace on the target cluster. - /// Returns name, storage class, capacity, and status for each PVC. - /// - Task> ListPvcsAsync(Guid clusterId, string ns, CancellationToken ct = default); -} - -/// -/// Summary of a PersistentVolumeClaim for display in the UI. -/// -public record PvcSummary(string Name, string? StorageClass, string? Capacity, string Status); diff --git a/src/EntKube.Provisioning/Features/Apps/Reconcile/ISecretResolver.cs b/src/EntKube.Provisioning/Features/Apps/Reconcile/ISecretResolver.cs deleted file mode 100644 index a090e34..0000000 --- a/src/EntKube.Provisioning/Features/Apps/Reconcile/ISecretResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// Abstracts secret resolution from the vault. The reconciler calls this -/// to fetch actual secret values when building the Kubernetes Secret manifest. -/// In production, this calls the Secrets microservice HTTP API. -/// -public interface ISecretResolver -{ - /// - /// Resolves a vault key to its plaintext value for a specific app scope. - /// Returns null if the key doesn't exist in the vault. - /// - Task ResolveAsync( - Guid tenantId, - Guid customerId, - string appSlug, - Guid environmentId, - string vaultKey, - CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Features/Apps/Reconcile/ManifestGenerator.cs b/src/EntKube.Provisioning/Features/Apps/Reconcile/ManifestGenerator.cs deleted file mode 100644 index a28d56a..0000000 --- a/src/EntKube.Provisioning/Features/Apps/Reconcile/ManifestGenerator.cs +++ /dev/null @@ -1,986 +0,0 @@ -using System.Text; -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// Generates Kubernetes manifest YAML from structured app specs. This is the -/// heart of the "ArgoCD without Git" approach — instead of reading manifests -/// from a repository, we generate them from the stored domain model. -/// -/// Each method produces a single Kubernetes resource as a YAML string: -/// - Deployment.yaml — container spec, replicas, env vars, resources -/// - Service.yaml — ClusterIP service mapping service port to container port -/// - HTTPRoute.yaml — Gateway API routing (hostname + path prefix) -/// - Secret.yaml — Opaque secret with resolved vault values -/// -/// The reconciler calls these methods, then applies the YAML to the target cluster. -/// -public static class ManifestGenerator -{ - /// - /// Generates a Kubernetes Namespace manifest. Applied when an app is first - /// connected to an environment, ensuring the namespace exists before any - /// workloads are deployed into it. - /// - public static string GenerateNamespace(string ns) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: Namespace"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - - return yaml.ToString(); - } - - /// - /// Generates a Kubernetes ServiceAccount manifest so a pod can run - /// with specific RBAC permissions in its namespace. - /// - public static string GenerateServiceAccount(string name, string ns) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: ServiceAccount"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {name}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - - return yaml.ToString(); - } - - /// - /// Generates a PersistentVolumeClaim manifest for storage that can be - /// mounted into pods via a VolumeSpec with a PVC source. - /// - public static string GeneratePersistentVolumeClaim( - string name, string ns, string storageSize, string accessMode = "ReadWriteOnce", string? storageClass = null) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: PersistentVolumeClaim"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {name}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("spec:"); - yaml.AppendLine(" accessModes:"); - yaml.AppendLine($" - {accessMode}"); - yaml.AppendLine(" resources:"); - yaml.AppendLine(" requests:"); - yaml.AppendLine($" storage: {storageSize}"); - - if (!string.IsNullOrEmpty(storageClass)) - { - yaml.AppendLine($" storageClassName: {storageClass}"); - } - - return yaml.ToString(); - } - - /// - /// Generates a Kubernetes Deployment manifest from a DeploymentSpec. - /// The deployment runs the specified container image with the given replicas, - /// ports, resource constraints, and environment variables. If secrets are - /// provided, they are injected as secretKeyRef entries so the container - /// picks them up from the companion Kubernetes Secret resource. - /// - /// Also supports: volumes, security context, init/sidecar containers, - /// probes, service account, and custom annotations/labels. - /// - public static string GenerateDeployment( - string appName, string ns, DeploymentSpec spec, List? secrets = null) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: apps/v1"); - yaml.AppendLine("kind: Deployment"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {appName}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - - // Custom labels on the Deployment metadata itself — useful for team - // ownership, cost center tracking, or custom selectors. - - if (spec.Labels is { Count: > 0 }) - { - foreach (KeyValuePair label in spec.Labels) - { - yaml.AppendLine($" {label.Key}: {label.Value}"); - } - } - - // Custom annotations on the Deployment metadata — used for Prometheus - // scrape config, Istio injection hints, or team-specific tooling. - - if (spec.Annotations is { Count: > 0 }) - { - yaml.AppendLine(" annotations:"); - - foreach (KeyValuePair annotation in spec.Annotations) - { - yaml.AppendLine($" {annotation.Key}: \"{annotation.Value}\""); - } - } - - yaml.AppendLine("spec:"); - yaml.AppendLine($" replicas: {spec.Replicas}"); - yaml.AppendLine(" selector:"); - yaml.AppendLine(" matchLabels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine(" template:"); - yaml.AppendLine(" metadata:"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - - if (spec.Labels is { Count: > 0 }) - { - foreach (KeyValuePair label in spec.Labels) - { - yaml.AppendLine($" {label.Key}: {label.Value}"); - } - } - - if (spec.Annotations is { Count: > 0 }) - { - yaml.AppendLine(" annotations:"); - - foreach (KeyValuePair annotation in spec.Annotations) - { - yaml.AppendLine($" {annotation.Key}: \"{annotation.Value}\""); - } - } - - yaml.AppendLine(" spec:"); - - // Image pull secrets — when images come from a private registry (Harbor - // or custom), the pod needs credentials to pull. The secret is created - // separately by the reconciler; here we just reference it by name. - - string? pullSecretName = ResolvePullSecretName(appName, spec); - - if (pullSecretName is not null) - { - yaml.AppendLine(" imagePullSecrets:"); - yaml.AppendLine($" - name: {pullSecretName}"); - } - - // Service account — when the workload needs to access the Kubernetes API - // or cloud resources via workload identity. - - if (!string.IsNullOrEmpty(spec.ServiceAccountName)) - { - yaml.AppendLine($" serviceAccountName: {spec.ServiceAccountName}"); - } - - // Pod-level security context — controls the user/group for all containers, - // filesystem group for volume permissions, and root-user restrictions. - - if (spec.SecurityContext is not null) - { - yaml.AppendLine(" securityContext:"); - - if (spec.SecurityContext.RunAsUser.HasValue) - { - yaml.AppendLine($" runAsUser: {spec.SecurityContext.RunAsUser.Value}"); - } - - if (spec.SecurityContext.RunAsGroup.HasValue) - { - yaml.AppendLine($" runAsGroup: {spec.SecurityContext.RunAsGroup.Value}"); - } - - if (spec.SecurityContext.FsGroup.HasValue) - { - yaml.AppendLine($" fsGroup: {spec.SecurityContext.FsGroup.Value}"); - } - - if (spec.SecurityContext.RunAsNonRoot == true) - { - yaml.AppendLine(" runAsNonRoot: true"); - } - } - - // Init containers run sequentially before the main container starts. - // Common uses: DB migrations, config rendering, waiting for dependencies. - - if (spec.Containers?.InitContainers is { Count: > 0 }) - { - yaml.AppendLine(" initContainers:"); - - foreach (ContainerSpec init in spec.Containers.InitContainers) - { - AppendContainerSpec(yaml, init, " "); - } - } - - // Main application container plus any sidecar containers. - - yaml.AppendLine(" containers:"); - - // Primary container — the main workload. - - yaml.AppendLine($" - name: {appName}"); - yaml.AppendLine($" image: {spec.Image}:{spec.Tag}"); - - if (spec.ContainerPort > 0) - { - yaml.AppendLine(" ports:"); - yaml.AppendLine($" - containerPort: {spec.ContainerPort}"); - } - - // Environment variables from the spec and secret references are - // combined into one env block. Spec env vars are plain values; - // secret env vars use secretKeyRef to pull from the companion Secret. - - bool hasEnvVars = (spec.EnvironmentVariables is not null && spec.EnvironmentVariables.Count > 0) - || (secrets is not null && secrets.Count > 0); - - if (hasEnvVars) - { - yaml.AppendLine(" env:"); - - if (spec.EnvironmentVariables is not null) - { - foreach (KeyValuePair envVar in spec.EnvironmentVariables) - { - yaml.AppendLine($" - name: {envVar.Key}"); - yaml.AppendLine($" value: \"{envVar.Value}\""); - } - } - - if (secrets is not null) - { - foreach (AppSecret secret in secrets) - { - yaml.AppendLine($" - name: {secret.Name}"); - yaml.AppendLine(" valueFrom:"); - yaml.AppendLine(" secretKeyRef:"); - yaml.AppendLine($" name: {appName}"); - yaml.AppendLine($" key: {secret.Name}"); - } - } - } - - // Resource requests and limits constrain how much CPU and memory - // the container can use. - - if (spec.Resources is not null && HasAnyResource(spec.Resources)) - { - AppendResourceSpec(yaml, spec.Resources, " "); - } - - // Volume mounts for the primary container — where each volume - // appears in the container filesystem. - - if (spec.Volumes is { Count: > 0 }) - { - List mountable = spec.Volumes.Where(v => v.MountPath is not null).ToList(); - - if (mountable.Count > 0) - { - yaml.AppendLine(" volumeMounts:"); - - foreach (VolumeSpec vol in mountable) - { - yaml.AppendLine($" - name: {vol.Name}"); - yaml.AppendLine($" mountPath: \"{vol.MountPath}\""); - - if (vol.SubPath is not null) - { - yaml.AppendLine($" subPath: \"{vol.SubPath}\""); - } - - if (vol.ReadOnly) - { - yaml.AppendLine(" readOnly: true"); - } - } - } - } - - // Probes — Kubernetes uses these to manage container lifecycle: - // liveness: restart if unhealthy - // readiness: remove from service until ready - // startup: don't check liveness/readiness until the app has started - - if (spec.Probes is not null) - { - if (spec.Probes.Liveness is not null) - { - AppendProbe(yaml, "livenessProbe", spec.Probes.Liveness, " "); - } - - if (spec.Probes.Readiness is not null) - { - AppendProbe(yaml, "readinessProbe", spec.Probes.Readiness, " "); - } - - if (spec.Probes.Startup is not null) - { - AppendProbe(yaml, "startupProbe", spec.Probes.Startup, " "); - } - } - - // Sidecar containers run alongside the main container for the - // entire pod lifetime (e.g., log shippers, auth proxies, metrics exporters). - - if (spec.Containers?.Sidecars is { Count: > 0 }) - { - foreach (ContainerSpec sidecar in spec.Containers.Sidecars) - { - AppendContainerSpec(yaml, sidecar, " "); - } - } - - // Volume definitions at the pod level — each volume is backed by a - // PVC, ConfigMap, Secret, or EmptyDir. - - if (spec.Volumes is { Count: > 0 }) - { - yaml.AppendLine(" volumes:"); - - foreach (VolumeSpec vol in spec.Volumes) - { - yaml.AppendLine($" - name: {vol.Name}"); - - switch (vol.Source) - { - case VolumePvcSource pvc: - yaml.AppendLine(" persistentVolumeClaim:"); - yaml.AppendLine($" claimName: {pvc.ClaimName}"); - break; - - case VolumeConfigMapSource cm: - yaml.AppendLine(" configMap:"); - yaml.AppendLine($" name: {cm.ConfigMapName}"); - break; - - case VolumeSecretSource sec: - yaml.AppendLine(" secret:"); - yaml.AppendLine($" secretName: {sec.SecretName}"); - break; - - case VolumeEmptyDirSource empty: - yaml.Append(" emptyDir: {}"); - - if (!string.IsNullOrEmpty(empty.SizeLimit)) - { - yaml.Length -= 1; // remove '}' - yaml.AppendLine(); - yaml.AppendLine($" sizeLimit: {empty.SizeLimit}"); - } - else - { - yaml.AppendLine(); - } - - break; - } - } - } - - return yaml.ToString(); - } - - /// - /// Generates a Kubernetes Service manifest from the legacy DeploymentSpec fields. - /// This overload uses ServicePort/ContainerPort and creates a single ClusterIP - /// service. Kept for backward compatibility — new code should use the ServiceSpec overload. - /// - public static string GenerateService(string appName, string ns, DeploymentSpec spec) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: Service"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {appName}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("spec:"); - yaml.AppendLine(" type: ClusterIP"); - yaml.AppendLine(" selector:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine(" ports:"); - yaml.AppendLine($" - port: {spec.ServicePort}"); - yaml.AppendLine($" targetPort: {spec.ContainerPort}"); - yaml.AppendLine(" protocol: TCP"); - - return yaml.ToString(); - } - - /// - /// Generates a Kubernetes Service manifest from a ServiceSpec. Each ServiceSpec - /// defines a named service with port, target port, protocol (TCP/UDP), and - /// service type (ClusterIP/NodePort/LoadBalancer). Pods are selected by the - /// app label. - /// - public static string GenerateService(string appName, string ns, ServiceSpec svc) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: Service"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {svc.Name}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("spec:"); - yaml.AppendLine($" type: {svc.Type}"); - yaml.AppendLine(" selector:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine(" ports:"); - yaml.AppendLine($" - port: {svc.Port}"); - yaml.AppendLine($" targetPort: {svc.TargetPort}"); - yaml.AppendLine($" protocol: {svc.Protocol}"); - - return yaml.ToString(); - } - - /// - /// Generates a Gateway API HTTPRoute manifest for ingress routing. - /// Only produces output when a hostname is configured — apps without - /// a hostname are internal services that don't need external routing. - /// - public static string GenerateHttpRoute(string appName, string ns, DeploymentSpec spec) - { - // No hostname means no ingress route needed. - - if (string.IsNullOrEmpty(spec.HostName)) - { - return string.Empty; - } - - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: gateway.networking.k8s.io/v1"); - yaml.AppendLine("kind: HTTPRoute"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {appName}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("spec:"); - yaml.AppendLine(" hostnames:"); - yaml.AppendLine($" - \"{spec.HostName}\""); - yaml.AppendLine(" rules:"); - yaml.AppendLine(" - matches:"); - yaml.AppendLine(" - path:"); - yaml.AppendLine(" type: PathPrefix"); - yaml.AppendLine($" value: \"{spec.PathPrefix ?? "/"}\""); - yaml.AppendLine(" backendRefs:"); - yaml.AppendLine($" - name: {appName}"); - yaml.AppendLine($" port: {spec.ServicePort}"); - - return yaml.ToString(); - } - - /// - /// Generates a Gateway API route manifest from a RouteSpec. Supports all - /// Gateway API route types: HTTPRoute, TLSRoute, TCPRoute, UDPRoute, GRPCRoute. - /// - /// Each route type uses the appropriate apiVersion and kind: - /// - HTTP → gateway.networking.k8s.io/v1 HTTPRoute - /// - TLS → gateway.networking.k8s.io/v1alpha2 TLSRoute - /// - TCP → gateway.networking.k8s.io/v1alpha2 TCPRoute - /// - UDP → gateway.networking.k8s.io/v1alpha2 UDPRoute - /// - GRPC → gateway.networking.k8s.io/v1 GRPCRoute - /// - /// Rules contain matches (path, headers), backend refs with optional weights - /// for canary deployments, and filters (header modification, rewrites, redirects). - /// - public static string GenerateRoute(string appName, string ns, RouteSpec route) - { - // Determine the apiVersion and kind based on the route type. - // HTTPRoute and GRPCRoute are GA (v1), everything else is v1alpha2. - - string type = route.Type.ToUpperInvariant(); - - (string apiVersion, string kind) = type switch - { - "HTTP" => ("gateway.networking.k8s.io/v1", "HTTPRoute"), - "GRPC" => ("gateway.networking.k8s.io/v1", "GRPCRoute"), - "TLS" => ("gateway.networking.k8s.io/v1alpha2", "TLSRoute"), - "TCP" => ("gateway.networking.k8s.io/v1alpha2", "TCPRoute"), - "UDP" => ("gateway.networking.k8s.io/v1alpha2", "UDPRoute"), - _ => ("gateway.networking.k8s.io/v1", "HTTPRoute") - }; - - StringBuilder yaml = new(); - - yaml.AppendLine($"apiVersion: {apiVersion}"); - yaml.AppendLine($"kind: {kind}"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {route.Name}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("spec:"); - - // Gateway reference — tells the route which gateway to attach to. - // If not provided, the route uses the cluster's default gateway. - - if (route.GatewayRef is not null) - { - yaml.AppendLine(" parentRefs:"); - yaml.AppendLine($" - name: {route.GatewayRef.Name}"); - - if (!string.IsNullOrEmpty(route.GatewayRef.Namespace)) - { - yaml.AppendLine($" namespace: {route.GatewayRef.Namespace}"); - } - } - - // Hostnames — used by HTTP, TLS, and GRPC routes for host-based routing. - // TCP and UDP routes don't use hostnames (they're port-based). - - if (route.Hostnames is { Count: > 0 }) - { - yaml.AppendLine(" hostnames:"); - - foreach (string hostname in route.Hostnames) - { - yaml.AppendLine($" - \"{hostname}\""); - } - } - - // Rules — each rule has matches, backend refs, and optional filters. - // For TCP/UDP/TLS routes, matches are typically empty (port-based routing). - - if (route.Rules is { Count: > 0 }) - { - yaml.AppendLine(" rules:"); - - foreach (RouteRule rule in route.Rules) - { - yaml.Append(" - "); - bool firstField = true; - - // Matches — path matching, header matching for HTTP/GRPC routes. - - if (rule.Matches is { Count: > 0 } && (type == "HTTP" || type == "GRPC")) - { - yaml.AppendLine("matches:"); - firstField = false; - - foreach (RouteMatch match in rule.Matches) - { - yaml.AppendLine(" - path:"); - yaml.AppendLine($" type: {match.MatchType}"); - yaml.AppendLine($" value: \"{match.Value}\""); - - if (match.Headers is { Count: > 0 }) - { - yaml.AppendLine(" headers:"); - - foreach (KeyValuePair header in match.Headers) - { - yaml.AppendLine($" - name: {header.Key}"); - yaml.AppendLine($" value: \"{header.Value}\""); - } - } - } - } - - // Filters — header modification, URL rewrites, redirects. - // Only applies to HTTP and GRPC routes. - - if (rule.Filters is { Count: > 0 } && (type == "HTTP" || type == "GRPC")) - { - if (firstField) - { - yaml.AppendLine("filters:"); - firstField = false; - } - else - { - yaml.AppendLine(" filters:"); - } - - foreach (RouteFilter filter in rule.Filters) - { - yaml.AppendLine($" - type: {filter.Type}"); - - if (filter.Type == "RequestHeaderModifier" && filter.HeadersToSet is { Count: > 0 }) - { - yaml.AppendLine(" requestHeaderModifier:"); - yaml.AppendLine(" set:"); - - foreach (KeyValuePair header in filter.HeadersToSet) - { - yaml.AppendLine($" - name: {header.Key}"); - yaml.AppendLine($" value: \"{header.Value}\""); - } - } - - if (filter.Type == "URLRewrite" && !string.IsNullOrEmpty(filter.RewritePath)) - { - yaml.AppendLine(" urlRewrite:"); - yaml.AppendLine(" path:"); - yaml.AppendLine(" type: ReplaceFullPath"); - yaml.AppendLine($" value: \"{filter.RewritePath}\""); - } - - if (filter.Type == "RequestRedirect" && !string.IsNullOrEmpty(filter.RedirectUrl)) - { - yaml.AppendLine(" requestRedirect:"); - yaml.AppendLine($" path: \"{filter.RedirectUrl}\""); - } - } - } - - // Backend references — the services that traffic is forwarded to. - // Weights enable canary deployments and traffic splitting. - - if (rule.BackendRefs is { Count: > 0 }) - { - if (firstField) - { - yaml.AppendLine("backendRefs:"); - } - else - { - yaml.AppendLine(" backendRefs:"); - } - - foreach (RouteBackendRef backendRef in rule.BackendRefs) - { - yaml.AppendLine($" - name: {backendRef.Name}"); - yaml.AppendLine($" port: {backendRef.Port}"); - - if (backendRef.Weight.HasValue) - { - yaml.AppendLine($" weight: {backendRef.Weight.Value}"); - } - } - } - } - } - - return yaml.ToString(); - } - - /// - /// Generates a Kubernetes Secret manifest from resolved secret values. - /// The values have already been fetched from the vault — this method - /// base64-encodes them into an Opaque Secret resource. - /// Returns empty string when there are no secrets. - /// - public static string GenerateSecret(string appName, string ns, Dictionary resolvedSecrets) - { - if (resolvedSecrets.Count == 0) - { - return string.Empty; - } - - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: Secret"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {appName}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("type: Opaque"); - yaml.AppendLine("data:"); - - foreach (KeyValuePair secret in resolvedSecrets) - { - string encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(secret.Value)); - yaml.AppendLine($" {secret.Key}: {encoded}"); - } - - return yaml.ToString(); - } - - private static bool HasAnyResource(ResourceSpec resources) - { - return !string.IsNullOrEmpty(resources.CpuRequest) - || !string.IsNullOrEmpty(resources.CpuLimit) - || !string.IsNullOrEmpty(resources.MemoryRequest) - || !string.IsNullOrEmpty(resources.MemoryLimit); - } - - /// - /// Generates a Kubernetes ConfigMap manifest. ConfigMaps hold non-sensitive - /// configuration data — config files, feature flags, or key-value settings. - /// Mount them into containers via VolumeConfigMapSource. - /// - public static string GenerateConfigMap(string appName, string ns, ConfigMapSpec configMap) - { - StringBuilder yaml = new(); - - yaml.AppendLine("apiVersion: v1"); - yaml.AppendLine("kind: ConfigMap"); - yaml.AppendLine("metadata:"); - yaml.AppendLine($" name: {configMap.Name}"); - yaml.AppendLine($" namespace: {ns}"); - yaml.AppendLine(" labels:"); - yaml.AppendLine($" app: {appName}"); - yaml.AppendLine($" app.kubernetes.io/managed-by: entkube"); - yaml.AppendLine("data:"); - - foreach (KeyValuePair entry in configMap.Data) - { - // For multi-line values, use YAML block scalar (|) notation. - // For single-line values, use quoted strings. - - if (entry.Value.Contains('\n')) - { - yaml.AppendLine($" {entry.Key}: |"); - - foreach (string line in entry.Value.Split('\n')) - { - yaml.AppendLine($" {line}"); - } - } - else - { - yaml.AppendLine($" {entry.Key}: \"{entry.Value}\""); - } - } - - return yaml.ToString(); - } - - // ─── Private Helpers ───────────────────────────────────────────────── - - /// - /// Appends a container spec to the YAML output. Used for both init containers - /// and sidecar containers — they share the same structure as the primary - /// container (image, ports, env, resources, volume mounts). - /// - private static void AppendContainerSpec(StringBuilder yaml, ContainerSpec container, string indent) - { - yaml.AppendLine($"{indent}- name: {container.Name}"); - yaml.AppendLine($"{indent} image: {container.Image}:{container.Tag}"); - - if (container.Command is { Count: > 0 }) - { - yaml.AppendLine($"{indent} command:"); - - foreach (string cmd in container.Command) - { - yaml.AppendLine($"{indent} - \"{cmd}\""); - } - } - - if (container.Args is { Count: > 0 }) - { - yaml.AppendLine($"{indent} args:"); - - foreach (string arg in container.Args) - { - yaml.AppendLine($"{indent} - \"{arg}\""); - } - } - - if (container.ContainerPort > 0) - { - yaml.AppendLine($"{indent} ports:"); - yaml.AppendLine($"{indent} - containerPort: {container.ContainerPort}"); - } - - if (container.EnvironmentVariables is { Count: > 0 }) - { - yaml.AppendLine($"{indent} env:"); - - foreach (KeyValuePair envVar in container.EnvironmentVariables) - { - yaml.AppendLine($"{indent} - name: {envVar.Key}"); - yaml.AppendLine($"{indent} value: \"{envVar.Value}\""); - } - } - - if (container.Resources is not null && HasAnyResource(container.Resources)) - { - AppendResourceSpec(yaml, container.Resources, $"{indent} "); - } - - if (container.VolumeMounts is { Count: > 0 }) - { - yaml.AppendLine($"{indent} volumeMounts:"); - - foreach (Domain.VolumeMount mount in container.VolumeMounts) - { - yaml.AppendLine($"{indent} - name: {mount.Name}"); - yaml.AppendLine($"{indent} mountPath: \"{mount.MountPath}\""); - - if (mount.SubPath is not null) - { - yaml.AppendLine($"{indent} subPath: \"{mount.SubPath}\""); - } - - if (mount.ReadOnly) - { - yaml.AppendLine($"{indent} readOnly: true"); - } - } - } - - if (container.SecurityContext is not null) - { - yaml.AppendLine($"{indent} securityContext:"); - - if (container.SecurityContext.ReadOnlyRootFilesystem == true) - { - yaml.AppendLine($"{indent} readOnlyRootFilesystem: true"); - } - - if (container.SecurityContext.AllowPrivilegeEscalation == false) - { - yaml.AppendLine($"{indent} allowPrivilegeEscalation: false"); - } - - if (container.SecurityContext.DropCapabilities is { Count: > 0 }) - { - yaml.AppendLine($"{indent} capabilities:"); - yaml.AppendLine($"{indent} drop:"); - - foreach (string cap in container.SecurityContext.DropCapabilities) - { - yaml.AppendLine($"{indent} - {cap}"); - } - } - - if (container.SecurityContext.AddCapabilities is { Count: > 0 }) - { - yaml.AppendLine($"{indent} capabilities:"); - yaml.AppendLine($"{indent} add:"); - - foreach (string cap in container.SecurityContext.AddCapabilities) - { - yaml.AppendLine($"{indent} - {cap}"); - } - } - } - } - - /// - /// Appends resource requests and limits to the YAML at the given indent level. - /// - private static void AppendResourceSpec(StringBuilder yaml, ResourceSpec resources, string indent) - { - yaml.AppendLine($"{indent}resources:"); - - if (!string.IsNullOrEmpty(resources.CpuRequest) || !string.IsNullOrEmpty(resources.MemoryRequest)) - { - yaml.AppendLine($"{indent} requests:"); - - if (!string.IsNullOrEmpty(resources.CpuRequest)) - { - yaml.AppendLine($"{indent} cpu: \"{resources.CpuRequest}\""); - } - - if (!string.IsNullOrEmpty(resources.MemoryRequest)) - { - yaml.AppendLine($"{indent} memory: \"{resources.MemoryRequest}\""); - } - } - - if (!string.IsNullOrEmpty(resources.CpuLimit) || !string.IsNullOrEmpty(resources.MemoryLimit)) - { - yaml.AppendLine($"{indent} limits:"); - - if (!string.IsNullOrEmpty(resources.CpuLimit)) - { - yaml.AppendLine($"{indent} cpu: \"{resources.CpuLimit}\""); - } - - if (!string.IsNullOrEmpty(resources.MemoryLimit)) - { - yaml.AppendLine($"{indent} memory: \"{resources.MemoryLimit}\""); - } - } - } - - /// - /// Appends a probe definition (liveness, readiness, or startup) to the YAML. - /// Supports HTTP GET, TCP socket, and exec probe types. - /// - private static void AppendProbe(StringBuilder yaml, string probeName, ProbeSpec probe, string indent) - { - yaml.AppendLine($"{indent}{probeName}:"); - - switch (probe.Type.ToUpperInvariant()) - { - case "HTTP": - yaml.AppendLine($"{indent} httpGet:"); - yaml.AppendLine($"{indent} path: {probe.Path}"); - yaml.AppendLine($"{indent} port: {probe.Port}"); - break; - - case "TCP": - yaml.AppendLine($"{indent} tcpSocket:"); - yaml.AppendLine($"{indent} port: {probe.Port}"); - break; - - case "EXEC": - if (probe.Command is { Count: > 0 }) - { - yaml.AppendLine($"{indent} exec:"); - yaml.AppendLine($"{indent} command:"); - - foreach (string cmd in probe.Command) - { - yaml.AppendLine($"{indent} - \"{cmd}\""); - } - } - - break; - } - - if (probe.InitialDelaySeconds > 0) - { - yaml.AppendLine($"{indent} initialDelaySeconds: {probe.InitialDelaySeconds}"); - } - - yaml.AppendLine($"{indent} periodSeconds: {probe.PeriodSeconds}"); - yaml.AppendLine($"{indent} failureThreshold: {probe.FailureThreshold}"); - } - - /// - /// Determines the imagePullSecret name for a deployment based on its - /// image registry configuration: - /// - Harbor → convention-based name: "harbor-pull-{project}-{appName}" - /// - Custom → uses the explicitly provided PullSecretName - /// - Public / null → no pull secret needed - /// - private static string? ResolvePullSecretName(string appName, DeploymentSpec spec) - { - if (spec.ImageRegistry is null) - { - return null; - } - - return spec.ImageRegistry.Source switch - { - ImageRegistrySource.Harbor when !string.IsNullOrEmpty(spec.ImageRegistry.HarborProject) - => $"harbor-pull-{spec.ImageRegistry.HarborProject}-{appName}", - ImageRegistrySource.Custom when !string.IsNullOrEmpty(spec.ImageRegistry.PullSecretName) - => spec.ImageRegistry.PullSecretName, - _ => null - }; - } -} diff --git a/src/EntKube.Provisioning/Features/Apps/RemoveAppEnvironment/RemoveAppEnvironmentHandler.cs b/src/EntKube.Provisioning/Features/Apps/RemoveAppEnvironment/RemoveAppEnvironmentHandler.cs deleted file mode 100644 index 65a7153..0000000 --- a/src/EntKube.Provisioning/Features/Apps/RemoveAppEnvironment/RemoveAppEnvironmentHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.RemoveAppEnvironment; - -/// -/// Handles removing an environment from an app. This removes the environment -/// entry from the database. The Kubernetes resources deployed to that environment -/// are NOT automatically cleaned up — that requires a separate teardown pass. -/// -public class RemoveAppEnvironmentHandler -{ - private readonly IAppRepository repository; - - public RemoveAppEnvironmentHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(RemoveAppEnvironmentRequest request, CancellationToken ct = default) - { - // Find the app and remove the specified environment. - - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure($"App with ID '{request.AppId}' was not found."); - } - - try - { - app.RemoveEnvironment(request.EnvironmentId); - await repository.UpdateAsync(app, ct); - return Result.Success(); - } - catch (InvalidOperationException ex) - { - return Result.Failure(ex.Message); - } - } -} - -public record RemoveAppEnvironmentRequest(Guid AppId, Guid EnvironmentId); diff --git a/src/EntKube.Provisioning/Features/Apps/RemoveAppSecret/RemoveAppSecretHandler.cs b/src/EntKube.Provisioning/Features/Apps/RemoveAppSecret/RemoveAppSecretHandler.cs deleted file mode 100644 index feeebac..0000000 --- a/src/EntKube.Provisioning/Features/Apps/RemoveAppSecret/RemoveAppSecretHandler.cs +++ /dev/null @@ -1,52 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.RemoveAppSecret; - -/// -/// Handles removing a secret reference from an app environment. The secret -/// entry is removed from the database — the next reconciliation cycle will -/// regenerate the K8s Secret resource without this key. -/// -public class RemoveAppSecretHandler -{ - private readonly IAppRepository repository; - - public RemoveAppSecretHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(RemoveAppSecretRequest request, CancellationToken ct = default) - { - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure($"App with ID '{request.AppId}' was not found."); - } - - // Find the environment on this app. - - AppEnvironment? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == request.EnvironmentId); - - if (env is null) - { - return Result.Failure( - $"Environment '{request.EnvironmentId}' not found on app '{request.AppId}'."); - } - - try - { - env.RemoveSecret(request.SecretName); - await repository.UpdateAsync(app, ct); - return Result.Success(); - } - catch (InvalidOperationException ex) - { - return Result.Failure(ex.Message); - } - } -} - -public record RemoveAppSecretRequest(Guid AppId, Guid EnvironmentId, string SecretName); diff --git a/src/EntKube.Provisioning/Features/Apps/SuspendActivateApp/SuspendActivateAppHandler.cs b/src/EntKube.Provisioning/Features/Apps/SuspendActivateApp/SuspendActivateAppHandler.cs deleted file mode 100644 index e47284b..0000000 --- a/src/EntKube.Provisioning/Features/Apps/SuspendActivateApp/SuspendActivateAppHandler.cs +++ /dev/null @@ -1,49 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.SuspendActivateApp; - -/// -/// Handles suspending and activating apps. A suspended app's environments -/// won't be reconciled — the reconciler skips suspended apps entirely. -/// Activating an app makes it eligible for reconciliation again. -/// -public class SuspendActivateAppHandler -{ - private readonly IAppRepository repository; - - public SuspendActivateAppHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task SuspendAsync(Guid appId, CancellationToken ct = default) - { - App? app = await repository.GetByIdAsync(appId, ct); - - if (app is null) - { - return Result.Failure($"App with ID '{appId}' was not found."); - } - - app.Suspend(); - await repository.UpdateAsync(app, ct); - - return Result.Success(); - } - - public async Task ActivateAsync(Guid appId, CancellationToken ct = default) - { - App? app = await repository.GetByIdAsync(appId, ct); - - if (app is null) - { - return Result.Failure($"App with ID '{appId}' was not found."); - } - - app.Activate(); - await repository.UpdateAsync(app, ct); - - return Result.Success(); - } -} diff --git a/src/EntKube.Provisioning/Features/Apps/TriggerSync/TriggerSyncHandler.cs b/src/EntKube.Provisioning/Features/Apps/TriggerSync/TriggerSyncHandler.cs deleted file mode 100644 index ee39f7a..0000000 --- a/src/EntKube.Provisioning/Features/Apps/TriggerSync/TriggerSyncHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.Apps.TriggerSync; - -/// -/// Handles the "sync now" action. Marks an environment as Pending so the -/// reconciler picks it up on the next cycle. This is useful for: -/// 1. Retrying after a failed deployment (Error → Pending) -/// 2. Forcing a re-deploy of already-synced environments -/// 3. Manually triggering sync after config changes -/// -public class TriggerSyncHandler -{ - private readonly IAppRepository repository; - - public TriggerSyncHandler(IAppRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(TriggerSyncRequest request, CancellationToken ct = default) - { - App? app = await repository.GetByIdAsync(request.AppId, ct); - - if (app is null) - { - return Result.Failure($"App with ID '{request.AppId}' was not found."); - } - - AppEnvironment? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == request.EnvironmentId); - - if (env is null) - { - return Result.Failure( - $"Environment '{request.EnvironmentId}' not found on app '{request.AppId}'."); - } - - // Mark as pending so the reconciler re-processes this environment. - - env.MarkPending(); - await repository.UpdateAsync(app, ct); - - return Result.Success(); - } -} - -public record TriggerSyncRequest(Guid AppId, Guid EnvironmentId); diff --git a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/DatabaseStatusReconcileHandler.cs b/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/DatabaseStatusReconcileHandler.cs deleted file mode 100644 index c4efbb1..0000000 --- a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/DatabaseStatusReconcileHandler.cs +++ /dev/null @@ -1,385 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MongoClusters; -using EntKube.Provisioning.Features.PostgresClusters; -using EntKube.Provisioning.Features.RedisClusters; -using Microsoft.Extensions.Logging; - -namespace EntKube.Provisioning.Features.DatabaseStatusReconcile; - -/// -/// Reconciles the internal status of all managed database clusters (CNPG, Redis, MongoDB) -/// with their live Kubernetes state. This handler runs on a timer and ensures that -/// clusters transition out of Provisioning/Upgrading once the operator reports them healthy. -/// -/// Without this reconciler, a cluster would stay in "Provisioning" forever after creation -/// because nothing called MarkRunning() on the domain model. The health endpoint only -/// returned live data without updating the persisted state. -/// -/// The reconciler handles three scenarios per cluster type: -/// 1. **Provisioning → Running**: K8s reports healthy → call MarkRunning() -/// 2. **Upgrading → Running**: K8s reports healthy after upgrade → call CompleteUpgrade() -/// 3. **Running → Degraded**: K8s reports unhealthy → call MarkDegraded() -/// 4. **Degraded → Running**: K8s reports recovered → call MarkRunning() -/// 5. **Failed phase**: K8s operator reports failure → call MarkDegraded() with reason -/// -public class DatabaseStatusReconcileHandler -{ - private readonly IPostgresClusterRepository pgRepository; - private readonly IRedisClusterRepository redisRepository; - private readonly IMongoClusterRepository mongoRepository; - private readonly ICnpgClusterClient cnpgClient; - private readonly IRedisClusterClient redisClient; - private readonly IMongoClusterClient mongoClient; - private readonly IClusterCredentialsResolver credentialsResolver; - private readonly ILogger? logger; - - public DatabaseStatusReconcileHandler( - IPostgresClusterRepository pgRepository, - IRedisClusterRepository redisRepository, - IMongoClusterRepository mongoRepository, - ICnpgClusterClient cnpgClient, - IRedisClusterClient redisClient, - IMongoClusterClient mongoClient, - IClusterCredentialsResolver credentialsResolver, - ILogger? logger = null) - { - this.pgRepository = pgRepository; - this.redisRepository = redisRepository; - this.mongoRepository = mongoRepository; - this.cnpgClient = cnpgClient; - this.redisClient = redisClient; - this.mongoClient = mongoClient; - this.credentialsResolver = credentialsResolver; - this.logger = logger; - } - - /// - /// Runs one full reconciliation cycle across all database cluster types. - /// Each cluster type is reconciled independently — a failure in one type - /// does not block the others. - /// - public async Task ReconcileAsync(CancellationToken ct) - { - await ReconcilePostgresClustersAsync(ct); - await ReconcileRedisClustersAsync(ct); - await ReconcileMongoClustersAsync(ct); - } - - // ─── CNPG PostgreSQL ───────────────────────────────────────────────── - - /// - /// Checks all CNPG PostgreSQL clusters that need status reconciliation. - /// A cluster needs reconciliation if it's in Provisioning, Running, Degraded, - /// or Upgrading state. Decommissioned clusters are skipped. - /// - private async Task ReconcilePostgresClustersAsync(CancellationToken ct) - { - IReadOnlyList clusters = await pgRepository.GetAllAsync(ct); - - foreach (PostgresCluster cluster in clusters) - { - // Skip clusters that don't need reconciliation. - - if (cluster.Status == PostgresClusterStatus.Decommissioned) - { - continue; - } - - try - { - await ReconcileSinglePostgresClusterAsync(cluster, ct); - } - catch (Exception ex) - { - // Log but don't crash — continue with the next cluster. - - logger?.LogWarning(ex, - "Failed to reconcile CNPG cluster '{Name}' ({Id})", - cluster.Name, cluster.Id); - } - } - } - - private async Task ReconcileSinglePostgresClusterAsync(PostgresCluster cluster, CancellationToken ct) - { - // Resolve kubeconfig for this cluster's target Kubernetes cluster. - - ClusterCredentials? creds = await credentialsResolver.ResolveAsync(cluster.ClusterId, ct); - - if (creds is null) - { - logger?.LogWarning( - "Could not resolve credentials for cluster {ClusterId}, skipping CNPG '{Name}'", - cluster.ClusterId, cluster.Name); - return; - } - - // Query the CNPG Cluster CR status from Kubernetes. - - CnpgClusterStatus? status = await cnpgClient.GetClusterStatusAsync( - creds.KubeConfig, creds.ContextName, cluster.Name, cluster.Namespace, ct); - - if (status is null) - { - return; - } - - // Determine the appropriate status transition based on what K8s reports. - // CNPG uses "Cluster in healthy state" as the phase for a fully healthy cluster. - - bool isHealthy = status.Phase.Contains("healthy", StringComparison.OrdinalIgnoreCase) - && status.ReadyInstances == status.TotalInstances - && status.TotalInstances > 0; - - bool isFailed = status.Phase.Contains("fail", StringComparison.OrdinalIgnoreCase); - - bool statusChanged = false; - - if (cluster.Status == PostgresClusterStatus.Upgrading && isHealthy) - { - // Upgrade completed — K8s reports healthy after version transition. - - cluster.CompleteUpgrade(); - statusChanged = true; - } - else if (cluster.Status == PostgresClusterStatus.Provisioning && isHealthy) - { - // Initial provisioning completed — cluster is now ready. - - cluster.MarkRunning(); - statusChanged = true; - } - else if ((cluster.Status == PostgresClusterStatus.Running || cluster.Status == PostgresClusterStatus.Degraded) - && isHealthy) - { - // Cluster is healthy. If it was degraded, recover it. - - if (cluster.Status == PostgresClusterStatus.Degraded) - { - cluster.MarkRunning(); - statusChanged = true; - } - } - else if (!isHealthy && (cluster.Status == PostgresClusterStatus.Running - || cluster.Status == PostgresClusterStatus.Provisioning && isFailed)) - { - // Not healthy — mark degraded with the reason from the operator. - - string reason = $"Phase: {status.Phase}, Ready: {status.ReadyInstances}/{status.TotalInstances}"; - cluster.MarkDegraded(reason); - statusChanged = true; - } - - if (statusChanged) - { - await pgRepository.UpdateAsync(cluster, ct); - - logger?.LogInformation( - "CNPG cluster '{Name}' status updated to {Status}", - cluster.Name, cluster.Status); - } - } - - // ─── Redis ─────────────────────────────────────────────────────────── - - /// - /// Checks all Redis clusters that need status reconciliation. - /// - private async Task ReconcileRedisClustersAsync(CancellationToken ct) - { - IReadOnlyList clusters = await redisRepository.GetAllAsync(ct); - - foreach (RedisCluster cluster in clusters) - { - if (cluster.Status == RedisClusterStatus.Decommissioned) - { - continue; - } - - try - { - await ReconcileSingleRedisClusterAsync(cluster, ct); - } - catch (Exception ex) - { - logger?.LogWarning(ex, - "Failed to reconcile Redis cluster '{Name}' ({Id})", - cluster.Name, cluster.Id); - } - } - } - - private async Task ReconcileSingleRedisClusterAsync(RedisCluster cluster, CancellationToken ct) - { - ClusterCredentials? creds = await credentialsResolver.ResolveAsync(cluster.ClusterId, ct); - - if (creds is null) - { - logger?.LogWarning( - "Could not resolve credentials for cluster {ClusterId}, skipping Redis '{Name}'", - cluster.ClusterId, cluster.Name); - return; - } - - RedisCrdStatus? status = await redisClient.GetClusterStatusAsync( - creds.KubeConfig, creds.ContextName, cluster.Name, cluster.Namespace, ct); - - if (status is null) - { - return; - } - - // The OT-OPERATORS Redis Operator uses "Ready" as the healthy phase. - // Some versions also use "Healthy" or report ready replicas matching total. - - bool isReady = status.Phase.Equals("Ready", StringComparison.OrdinalIgnoreCase) - || status.Phase.Equals("Healthy", StringComparison.OrdinalIgnoreCase) - || (status.ReadyReplicas > 0 && status.ReadyReplicas == status.DesiredReplicas); - - bool isFailed = status.Phase.Contains("fail", StringComparison.OrdinalIgnoreCase) - || status.Phase.Contains("error", StringComparison.OrdinalIgnoreCase); - - bool statusChanged = false; - - if (cluster.Status == RedisClusterStatus.Upgrading && isReady) - { - cluster.CompleteUpgrade(); - statusChanged = true; - } - else if (cluster.Status == RedisClusterStatus.Provisioning && isReady) - { - cluster.MarkRunning(); - statusChanged = true; - } - else if ((cluster.Status == RedisClusterStatus.Running || cluster.Status == RedisClusterStatus.Degraded) - && isReady) - { - if (cluster.Status == RedisClusterStatus.Degraded) - { - cluster.MarkRunning(); - statusChanged = true; - } - } - else if (!isReady && (cluster.Status == RedisClusterStatus.Running - || cluster.Status == RedisClusterStatus.Provisioning && isFailed)) - { - string reason = $"Phase: {status.Phase}, Ready: {status.ReadyReplicas}/{status.DesiredReplicas}"; - cluster.MarkDegraded(reason); - statusChanged = true; - } - - if (statusChanged) - { - await redisRepository.UpdateAsync(cluster, ct); - - logger?.LogInformation( - "Redis cluster '{Name}' status updated to {Status}", - cluster.Name, cluster.Status); - } - } - - // ─── MongoDB ───────────────────────────────────────────────────────── - - /// - /// Checks all MongoDB clusters that need status reconciliation. - /// - private async Task ReconcileMongoClustersAsync(CancellationToken ct) - { - IReadOnlyList clusters = await mongoRepository.GetAllAsync(ct); - - foreach (MongoCluster cluster in clusters) - { - if (cluster.Status == MongoClusterStatus.Decommissioned) - { - continue; - } - - try - { - await ReconcileSingleMongoClusterAsync(cluster, ct); - } - catch (Exception ex) - { - logger?.LogWarning(ex, - "Failed to reconcile MongoDB cluster '{Name}' ({Id})", - cluster.Name, cluster.Id); - } - } - } - - private async Task ReconcileSingleMongoClusterAsync(MongoCluster cluster, CancellationToken ct) - { - ClusterCredentials? creds = await credentialsResolver.ResolveAsync(cluster.ClusterId, ct); - - if (creds is null) - { - logger?.LogWarning( - "Could not resolve credentials for cluster {ClusterId}, skipping MongoDB '{Name}'", - cluster.ClusterId, cluster.Name); - return; - } - - MongoCrdStatus? status = await mongoClient.GetClusterStatusAsync( - creds.KubeConfig, creds.ContextName, cluster.Name, cluster.Namespace, ct); - - if (status is null) - { - return; - } - - // MongoDB Community Operator uses "Running" as the healthy phase. - - bool isRunning = status.Phase.Equals("Running", StringComparison.OrdinalIgnoreCase) - && status.ReadyMembers == status.TotalMembers - && status.TotalMembers > 0; - - bool isFailed = status.Phase.Contains("fail", StringComparison.OrdinalIgnoreCase) - || status.Phase.Contains("error", StringComparison.OrdinalIgnoreCase); - - bool statusChanged = false; - - if (cluster.Status == MongoClusterStatus.Upgrading && isRunning) - { - cluster.CompleteUpgrade(); - statusChanged = true; - } - else if (cluster.Status == MongoClusterStatus.Provisioning && isRunning) - { - cluster.MarkRunning(); - statusChanged = true; - } - else if (cluster.Status == MongoClusterStatus.Provisioning && isFailed) - { - // The operator reports a failure during provisioning — mark degraded - // so the admin can see the problem in the UI. - - string reason = $"Phase: {status.Phase}, Ready: {status.ReadyMembers}/{status.TotalMembers}"; - cluster.MarkDegraded(reason); - statusChanged = true; - } - else if ((cluster.Status == MongoClusterStatus.Running || cluster.Status == MongoClusterStatus.Degraded) - && isRunning) - { - if (cluster.Status == MongoClusterStatus.Degraded) - { - cluster.MarkRunning(); - statusChanged = true; - } - } - else if (!isRunning && cluster.Status == MongoClusterStatus.Running) - { - string reason = $"Phase: {status.Phase}, Ready: {status.ReadyMembers}/{status.TotalMembers}"; - cluster.MarkDegraded(reason); - statusChanged = true; - } - - if (statusChanged) - { - await mongoRepository.UpdateAsync(cluster, ct); - - logger?.LogInformation( - "MongoDB cluster '{Name}' status updated to {Status}", - cluster.Name, cluster.Status); - } - } -} diff --git a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/DatabaseStatusReconcilerService.cs b/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/DatabaseStatusReconcilerService.cs deleted file mode 100644 index eb36ffc..0000000 --- a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/DatabaseStatusReconcilerService.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace EntKube.Provisioning.Features.DatabaseStatusReconcile; - -/// -/// A background service that periodically reconciles the status of all managed -/// database clusters (CNPG, Redis, MongoDB) with their live Kubernetes state. -/// -/// Every 30 seconds it wakes up, creates a new scope (to get fresh DbContext -/// and dependencies), and runs the reconcile handler. This is the heartbeat -/// that transitions database clusters from Provisioning → Running once the -/// Kubernetes operator reports them as healthy. -/// -/// Without this service, database clusters would stay in "Provisioning" forever -/// because nothing polled K8s to check if they were actually ready. -/// -public class DatabaseStatusReconcilerService : BackgroundService -{ - private readonly IServiceProvider serviceProvider; - private readonly ILogger logger; - private readonly TimeSpan interval = TimeSpan.FromSeconds(30); - - public DatabaseStatusReconcilerService( - IServiceProvider serviceProvider, - ILogger logger) - { - this.serviceProvider = serviceProvider; - this.logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation( - "Database status reconciler started. Polling every {Interval}s.", interval.TotalSeconds); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - // Each cycle gets its own scope so the DbContext and repositories - // are fresh — no stale tracking entities across cycles. - - using (IServiceScope scope = serviceProvider.CreateScope()) - { - DatabaseStatusReconcileHandler handler = - scope.ServiceProvider.GetRequiredService(); - - await handler.ReconcileAsync(stoppingToken); - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - // Graceful shutdown — exit the loop. - - break; - } - catch (Exception ex) - { - // Log the error but don't crash. The reconciler will try again - // on the next tick. - - logger.LogError(ex, "Database status reconciliation cycle failed."); - } - - await Task.Delay(interval, stoppingToken); - } - - logger.LogInformation("Database status reconciler stopped."); - } -} diff --git a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/HttpClusterCredentialsResolver.cs b/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/HttpClusterCredentialsResolver.cs deleted file mode 100644 index 85a81dd..0000000 --- a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/HttpClusterCredentialsResolver.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; - -namespace EntKube.Provisioning.Features.DatabaseStatusReconcile; - -/// -/// Resolves cluster credentials by calling the Clusters service API. -/// Uses the same pattern as KubernetesApplyClient.GetClusterCredentialsAsync — -/// calls GET /api/clusters/{id}/credentials and parses the ApiResponse envelope. -/// -public class HttpClusterCredentialsResolver : IClusterCredentialsResolver -{ - private readonly IHttpClientFactory httpClientFactory; - private readonly ILogger logger; - - public HttpClusterCredentialsResolver( - IHttpClientFactory httpClientFactory, - ILogger logger) - { - this.httpClientFactory = httpClientFactory; - this.logger = logger; - } - - public async Task ResolveAsync(Guid clusterId, CancellationToken ct = default) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/credentials", ct); - - if (!response.IsSuccessStatusCode) - { - logger.LogWarning( - "Failed to fetch credentials for cluster {ClusterId}: {StatusCode}", - clusterId, response.StatusCode); - return null; - } - - // The Clusters service returns ApiResponse with - // { success: true, data: { kubeConfig: "...", contextName: "..." } } - - JsonElement json = await response.Content.ReadFromJsonAsync(ct); - JsonElement data = json.GetProperty("data"); - - string? kubeConfig = data.GetProperty("kubeConfig").GetString(); - string? contextName = data.GetProperty("contextName").GetString(); - - if (kubeConfig is null || contextName is null) - { - logger.LogWarning("Credentials response for cluster {ClusterId} had null values", clusterId); - return null; - } - - return new ClusterCredentials(kubeConfig, contextName); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Exception resolving credentials for cluster {ClusterId}", clusterId); - return null; - } - } -} diff --git a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/IClusterCredentialsResolver.cs b/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/IClusterCredentialsResolver.cs deleted file mode 100644 index e5b8fb8..0000000 --- a/src/EntKube.Provisioning/Features/DatabaseStatusReconcile/IClusterCredentialsResolver.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace EntKube.Provisioning.Features.DatabaseStatusReconcile; - -/// -/// Resolves kubeconfig credentials for a registered Kubernetes cluster by calling -/// the Clusters service. This is extracted as an interface so the reconciler can -/// be tested without real HTTP calls. -/// -public interface IClusterCredentialsResolver -{ - /// - /// Fetches the kubeconfig and context name for the given cluster ID - /// from the Clusters service. - /// - Task ResolveAsync(Guid clusterId, CancellationToken ct = default); -} - -/// -/// Kubeconfig and context name for a registered Kubernetes cluster. -/// -public record ClusterCredentials(string KubeConfig, string ContextName); diff --git a/src/EntKube.Provisioning/Features/DecommissionService/DecommissionServiceEndpoint.cs b/src/EntKube.Provisioning/Features/DecommissionService/DecommissionServiceEndpoint.cs deleted file mode 100644 index 0ee2c95..0000000 --- a/src/EntKube.Provisioning/Features/DecommissionService/DecommissionServiceEndpoint.cs +++ /dev/null @@ -1,30 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.DecommissionService; - -/// -/// Maps POST /api/services/{id}/decommission — triggers decommissioning of a service. -/// Returns 200 on success or 404 if the service doesn't exist. -/// -public static class DecommissionServiceEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/services/{id:guid}/decommission", async ( - Guid id, - [FromServices] DecommissionServiceHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(id, ct); - - if (result.IsFailure) - { - return Results.NotFound(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok("Service decommission requested.")); - }); - } -} diff --git a/src/EntKube.Provisioning/Features/DecommissionService/DecommissionServiceHandler.cs b/src/EntKube.Provisioning/Features/DecommissionService/DecommissionServiceHandler.cs deleted file mode 100644 index c0a4c4f..0000000 --- a/src/EntKube.Provisioning/Features/DecommissionService/DecommissionServiceHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.DecommissionService; - -/// -/// Handles requests to decommission a provisioned service. When a tenant admin -/// no longer needs a service, they request decommission. This sets the desired -/// state to Decommissioned — the reconciliation loop will handle the actual -/// teardown (removing Helm releases, cleaning up resources, etc.). -/// -public class DecommissionServiceHandler -{ - private readonly IServiceInstanceRepository repository; - - public DecommissionServiceHandler(IServiceInstanceRepository repository) - { - this.repository = repository; - } - - public async Task HandleAsync(Guid id, CancellationToken ct = default) - { - // Look up the service instance. If it doesn't exist, this is either - // a stale request or a bug in the caller. - - ServiceInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Result.Failure($"Service instance with ID '{id}' was not found."); - } - - // Tell the domain to transition the desired state. The current state - // won't change until the reconciler actually tears it down. - - instance.RequestDecommission(); - await repository.UpdateAsync(instance, ct); - - return Result.Success(); - } -} diff --git a/src/EntKube.Provisioning/Features/GetServices/GetServicesEndpoint.cs b/src/EntKube.Provisioning/Features/GetServices/GetServicesEndpoint.cs deleted file mode 100644 index 24ee946..0000000 --- a/src/EntKube.Provisioning/Features/GetServices/GetServicesEndpoint.cs +++ /dev/null @@ -1,44 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.GetServices; - -/// -/// Lists all provisioned service instances. Optionally filtered by cluster. -/// -public static class GetServicesEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/services", async ( - [FromQuery] Guid? clusterId, - [FromServices] IServiceInstanceRepository repository, - CancellationToken ct) => - { - IReadOnlyList instances = clusterId.HasValue - ? await repository.GetByClusterIdAsync(clusterId.Value, ct) - : await repository.GetAllAsync(ct); - - List summaries = instances.Select(s => new ServiceSummary( - s.Id, - s.ClusterId, - s.ServiceType.ToString(), - s.Name, - s.Namespace, - s.CurrentState.ToString(), - s.DesiredState.ToString())).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - } -} - -public record ServiceSummary( - Guid Id, - Guid ClusterId, - string ServiceType, - string Name, - string Namespace, - string CurrentState, - string DesiredState); diff --git a/src/EntKube.Provisioning/Features/IdentityRealms/IdentityRealmEndpoints.cs b/src/EntKube.Provisioning/Features/IdentityRealms/IdentityRealmEndpoints.cs deleted file mode 100644 index 5f41584..0000000 --- a/src/EntKube.Provisioning/Features/IdentityRealms/IdentityRealmEndpoints.cs +++ /dev/null @@ -1,369 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.IdentityRealms; - -/// -/// Maps Identity Realm endpoints. These manage the full lifecycle of realms — -/// creating, listing, updating branding/pages, managing identity providers, -/// and managing organizations within each realm. -/// -/// Realms are scoped to an environment. Each environment can have many realms, each with -/// its own identity providers, branding, pages, and organization hierarchy. -/// The realm physically runs on a Keycloak instance deployed on a cluster within the environment. -/// -public static class IdentityRealmEndpoints -{ - public static void Map(WebApplication app) - { - RouteGroupBuilder group = app.MapGroup("/api/identity-realms"); - - // POST /api/identity-realms — Create a new realm in an environment. - - group.MapPost("/", async ( - [FromBody] CreateRealmRequest body, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm realm = IdentityRealm.Create(body.EnvironmentId, body.Name, body.ClusterId); - realm.MarkActive(); - - await repository.AddAsync(realm, ct); - - return Results.Created($"/api/identity-realms/{realm.Id}", - ApiResponse.Ok(MapToDto(realm))); - }); - - // GET /api/identity-realms/environment/{environmentId} — List realms for an environment. - - group.MapGet("/environment/{environmentId:guid}", async ( - Guid environmentId, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IReadOnlyList realms = await repository.GetByEnvironmentIdAsync(environmentId, ct); - - List dtos = realms.Select(MapToDto).ToList(); - - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - - // GET /api/identity-realms/{id} — Get a single realm. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - return Results.Ok(ApiResponse.Ok(MapToDto(realm))); - }); - - // DELETE /api/identity-realms/{id} — Delete a realm. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - await repository.DeleteAsync(id, ct); - - return Results.Ok(ApiResponse.Ok(new { deleted = true })); - }); - - // PUT /api/identity-realms/{id}/branding — Update realm branding. - - group.MapPut("/{id:guid}/branding", async ( - Guid id, - [FromBody] UpdateBrandingRequest body, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - RealmBranding branding = new( - body.LogoUrl, - body.BackgroundImageUrl, - body.BackgroundColor, - body.PrimaryColor, - body.SecondaryColor, - body.TextColor); - - realm.UpdateBranding(branding); - await repository.UpdateAsync(realm, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(realm))); - }); - - // PUT /api/identity-realms/{id}/pages — Update realm pages configuration. - - group.MapPut("/{id:guid}/pages", async ( - Guid id, - [FromBody] UpdatePagesRequest body, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - RealmPages pages = new( - body.LoginEnabled, - body.ProfileEnabled, - body.RegistrationEnabled, - body.ForgotPasswordEnabled); - - realm.UpdatePages(pages); - await repository.UpdateAsync(realm, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(realm))); - }); - - // POST /api/identity-realms/{id}/identity-providers — Add an identity provider. - - group.MapPost("/{id:guid}/identity-providers", async ( - Guid id, - [FromBody] AddIdentityProviderRequest body, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - RealmIdentityProvider provider = RealmIdentityProvider.Create( - body.Alias, - body.DisplayName, - body.Type, - body.AuthorizationUrl, - body.TokenUrl, - body.ClientId, - body.ClientSecret, - body.MetadataUrl); - - realm.AddIdentityProvider(provider); - await repository.UpdateAsync(realm, ct); - - return Results.Created($"/api/identity-realms/{id}/identity-providers/{provider.Id}", - ApiResponse.Ok(MapProviderToDto(provider))); - }); - - // DELETE /api/identity-realms/{id}/identity-providers/{alias} — Remove an identity provider. - - group.MapDelete("/{id:guid}/identity-providers/{alias}", async ( - Guid id, - string alias, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - try - { - realm.RemoveIdentityProvider(alias); - await repository.UpdateAsync(realm, ct); - return Results.Ok(ApiResponse.Ok(new { deleted = true })); - } - catch (InvalidOperationException ex) - { - return Results.NotFound(ApiResponse.Fail(ex.Message)); - } - }); - - // POST /api/identity-realms/{id}/organizations — Add a top-level organization. - - group.MapPost("/{id:guid}/organizations", async ( - Guid id, - [FromBody] AddOrganizationRequest body, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - RealmOrganization org = RealmOrganization.Create(body.Name, body.Description); - realm.AddOrganization(org); - await repository.UpdateAsync(realm, ct); - - return Results.Created($"/api/identity-realms/{id}/organizations/{org.Id}", - ApiResponse.Ok(MapOrgToDto(org))); - }); - - // DELETE /api/identity-realms/{id}/organizations/{name} — Remove an organization. - - group.MapDelete("/{id:guid}/organizations/{name}", async ( - Guid id, - string name, - [FromServices] IIdentityRealmRepository repository, - CancellationToken ct) => - { - IdentityRealm? realm = await repository.GetByIdAsync(id, ct); - - if (realm is null) - { - return Results.NotFound(ApiResponse.Fail($"Realm {id} not found.")); - } - - try - { - realm.RemoveOrganization(name); - await repository.UpdateAsync(realm, ct); - return Results.Ok(ApiResponse.Ok(new { deleted = true })); - } - catch (InvalidOperationException ex) - { - return Results.NotFound(ApiResponse.Fail(ex.Message)); - } - }); - } - - private static RealmDto MapToDto(IdentityRealm realm) => new( - Id: realm.Id, - EnvironmentId: realm.EnvironmentId, - ClusterId: realm.ClusterId, - Name: realm.Name, - Status: realm.Status.ToString(), - Branding: new BrandingDto( - realm.Branding.LogoUrl, - realm.Branding.BackgroundImageUrl, - realm.Branding.BackgroundColor, - realm.Branding.PrimaryColor, - realm.Branding.SecondaryColor, - realm.Branding.TextColor), - Pages: new PagesDto( - realm.Pages.LoginEnabled, - realm.Pages.ProfileEnabled, - realm.Pages.RegistrationEnabled, - realm.Pages.ForgotPasswordEnabled), - IdentityProviders: realm.IdentityProviders.Select(MapProviderToDto).ToList(), - Organizations: realm.Organizations.Select(MapOrgToDto).ToList(), - CreatedAt: realm.CreatedAt, - LastModifiedAt: realm.LastModifiedAt); - - private static IdentityProviderDto MapProviderToDto(RealmIdentityProvider p) => new( - Id: p.Id, - Alias: p.Alias, - DisplayName: p.DisplayName, - Type: p.Type.ToString(), - Enabled: p.Enabled, - AuthorizationUrl: p.AuthorizationUrl, - TokenUrl: p.TokenUrl, - ClientId: p.ClientId, - MetadataUrl: p.MetadataUrl); - - private static OrganizationDto MapOrgToDto(RealmOrganization o) => new( - Id: o.Id, - Name: o.Name, - Description: o.Description, - Children: o.Children.Select(MapOrgToDto).ToList()); -} - -// --- Request DTOs --- - -public record CreateRealmRequest(Guid EnvironmentId, Guid ClusterId, string Name); - -public record UpdateBrandingRequest( - string? LogoUrl, - string? BackgroundImageUrl, - string? BackgroundColor, - string? PrimaryColor, - string? SecondaryColor, - string? TextColor); - -public record UpdatePagesRequest( - bool LoginEnabled, - bool ProfileEnabled, - bool RegistrationEnabled, - bool ForgotPasswordEnabled); - -public record AddIdentityProviderRequest( - string Alias, - string DisplayName, - IdentityProviderType Type, - string? AuthorizationUrl = null, - string? TokenUrl = null, - string? ClientId = null, - string? ClientSecret = null, - string? MetadataUrl = null); - -public record AddOrganizationRequest(string Name, string? Description = null); - -// --- Response DTOs --- - -public record RealmDto( - Guid Id, - Guid EnvironmentId, - Guid ClusterId, - string Name, - string Status, - BrandingDto Branding, - PagesDto Pages, - List IdentityProviders, - List Organizations, - DateTimeOffset CreatedAt, - DateTimeOffset? LastModifiedAt); - -public record BrandingDto( - string? LogoUrl, - string? BackgroundImageUrl, - string? BackgroundColor, - string? PrimaryColor, - string? SecondaryColor, - string? TextColor); - -public record PagesDto( - bool LoginEnabled, - bool ProfileEnabled, - bool RegistrationEnabled, - bool ForgotPasswordEnabled); - -public record IdentityProviderDto( - Guid Id, - string Alias, - string DisplayName, - string Type, - bool Enabled, - string? AuthorizationUrl, - string? TokenUrl, - string? ClientId, - string? MetadataUrl); - -public record OrganizationDto( - Guid Id, - string Name, - string? Description, - List Children); diff --git a/src/EntKube.Provisioning/Features/MinioInstances/AdoptMinioInstance/AdoptMinioInstanceHandler.cs b/src/EntKube.Provisioning/Features/MinioInstances/AdoptMinioInstance/AdoptMinioInstanceHandler.cs deleted file mode 100644 index 611f7e5..0000000 --- a/src/EntKube.Provisioning/Features/MinioInstances/AdoptMinioInstance/AdoptMinioInstanceHandler.cs +++ /dev/null @@ -1,72 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance; - -/// -/// Handles requests to adopt an existing MinIO deployment on a Kubernetes cluster. -/// Adoption means: discover the MinIO tenant, capture its details, and store it so -/// CNPG clusters can reference it when configuring backups. -/// -/// The flow: -/// 1. Check if MinIO was already adopted for this cluster (idempotent) -/// 2. Discover MinIO on the cluster via K8s API -/// 3. Create the MinioInstance aggregate and persist it -/// -public class AdoptMinioInstanceHandler -{ - private readonly IMinioInstanceRepository repository; - private readonly IMinioClient minioClient; - - public AdoptMinioInstanceHandler(IMinioInstanceRepository repository, IMinioClient minioClient) - { - this.repository = repository; - this.minioClient = minioClient; - } - - public async Task> HandleAsync(AdoptMinioInstanceRequest request, CancellationToken ct = default) - { - // First, check if we've already adopted MinIO for this cluster. - // If so, return the existing instance — adoption is idempotent. - - MinioInstance? existing = await repository.GetByClusterIdAsync(request.ClusterId, ct); - - if (existing is not null) - { - return Result.Success(existing.Id); - } - - // Discover the MinIO tenant on the Kubernetes cluster. The client looks - // for MinIO Tenant CRDs or well-known MinIO StatefulSets/Deployments. - - DiscoveredMinioInstance? discovered = await minioClient.DiscoverMinioAsync( - request.KubeConfig, request.ContextName, ct); - - if (discovered is null) - { - return Result.Failure("No MinIO deployment found on the cluster."); - } - - // Create the aggregate from the discovered details and persist it. - - MinioInstance instance = MinioInstance.Adopt( - clusterId: request.ClusterId, - name: discovered.Name, - ns: discovered.Namespace, - endpoint: discovered.Endpoint, - consoleEndpoint: discovered.ConsoleEndpoint, - credentialsSecret: discovered.CredentialsSecret, - totalCapacity: discovered.TotalCapacity, - usedCapacity: discovered.UsedCapacity, - buckets: discovered.Buckets); - - await repository.AddAsync(instance, ct); - - return Result.Success(instance.Id); - } -} - -public record AdoptMinioInstanceRequest( - Guid ClusterId, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/MinioInstances/IMinioClient.cs b/src/EntKube.Provisioning/Features/MinioInstances/IMinioClient.cs deleted file mode 100644 index 8e948dd..0000000 --- a/src/EntKube.Provisioning/Features/MinioInstances/IMinioClient.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace EntKube.Provisioning.Features.MinioInstances; - -/// -/// Defines the contract for discovering MinIO deployments on a Kubernetes cluster. -/// The platform doesn't provision MinIO — it discovers an existing tenant and adopts it -/// so that CNPG clusters can reference it for WAL archiving and backups. -/// -public interface IMinioClient -{ - /// - /// Discovers a MinIO tenant on the specified K8s cluster by looking for MinIO - /// Tenant CRDs or well-known MinIO StatefulSets/Deployments. - /// Returns null if no MinIO deployment is found. - /// - Task DiscoverMinioAsync(string kubeConfig, string contextName, CancellationToken ct = default); -} - -/// -/// Metadata about a MinIO deployment discovered on a Kubernetes cluster. -/// -public record DiscoveredMinioInstance( - string Name, - string Namespace, - string Endpoint, - string? ConsoleEndpoint, - string CredentialsSecret, - string? TotalCapacity, - string? UsedCapacity, - List? Buckets); diff --git a/src/EntKube.Provisioning/Features/MinioInstances/KubernetesMinioClient.cs b/src/EntKube.Provisioning/Features/MinioInstances/KubernetesMinioClient.cs deleted file mode 100644 index 76dfd68..0000000 --- a/src/EntKube.Provisioning/Features/MinioInstances/KubernetesMinioClient.cs +++ /dev/null @@ -1,123 +0,0 @@ -using k8s; -using k8s.Models; -using System.Text.Json; - -namespace EntKube.Provisioning.Features.MinioInstances; - -/// -/// Discovers MinIO tenants on Kubernetes clusters by looking for MinIO Tenant CRDs -/// (minio.min.io/v2) or well-known MinIO StatefulSets. Returns the first tenant found. -/// -public class KubernetesMinioClient : IMinioClient -{ - public async Task DiscoverMinioAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // Build a Kubernetes client from the provided kubeconfig. - - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - new MemoryStream(System.Text.Encoding.UTF8.GetBytes(kubeConfig)), - currentContext: contextName); - - Kubernetes client = new(config); - - // Try to find MinIO Tenant CRDs first (operator-based installs). - - try - { - JsonElement tenantsResponse = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "minio.min.io", - version: "v2", - plural: "tenants", - cancellationToken: ct); - - if (tenantsResponse.TryGetProperty("items", out JsonElement items) && - items.GetArrayLength() > 0) - { - JsonElement tenant = items[0]; - JsonElement metadata = tenant.GetProperty("metadata"); - string name = metadata.GetProperty("name").GetString() ?? "unknown"; - string ns = metadata.GetProperty("namespace").GetString() ?? "default"; - - // Build endpoint from service name convention: -hl..svc:9000 - - string endpoint = $"{name}-hl.{ns}.svc:9000"; - string? consoleEndpoint = $"{name}-console.{ns}.svc:9443"; - - // Look for credentials secret — MinIO tenants typically have a secret - // referenced in spec.credsSecret or the tenant name suffixed with "-secret" - - string credentialsSecret = $"{name}-secret"; - - if (tenant.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("credsSecret", out JsonElement credsSecret) && - credsSecret.TryGetProperty("name", out JsonElement secretName)) - { - credentialsSecret = secretName.GetString() ?? credentialsSecret; - } - - // Discover capacity from spec.pools - - string? totalCapacity = null; - - if (spec.TryGetProperty("pools", out JsonElement pools) && - pools.GetArrayLength() > 0) - { - JsonElement firstPool = pools[0]; - - if (firstPool.TryGetProperty("volumeClaimTemplate", out JsonElement vct) && - vct.TryGetProperty("spec", out JsonElement vctSpec) && - vctSpec.TryGetProperty("resources", out JsonElement resources) && - resources.TryGetProperty("requests", out JsonElement requests) && - requests.TryGetProperty("storage", out JsonElement storage)) - { - totalCapacity = storage.GetString(); - } - } - - // Discover existing buckets is not trivially available from the CRD, - // so we return an empty list. The reconciler can populate this later via mc admin. - - return new DiscoveredMinioInstance( - Name: name, - Namespace: ns, - Endpoint: endpoint, - ConsoleEndpoint: consoleEndpoint, - CredentialsSecret: credentialsSecret, - TotalCapacity: totalCapacity, - UsedCapacity: null, - Buckets: new List()); - } - } - catch (k8s.Autorest.HttpOperationException) - { - // Tenant CRD not installed — fall through to StatefulSet-based discovery. - } - - // Fallback: Look for StatefulSets with "minio" label (standalone deploys). - - V1StatefulSetList statefulSets = await client.AppsV1.ListStatefulSetForAllNamespacesAsync( - labelSelector: "app=minio", - cancellationToken: ct); - - if (statefulSets.Items.Count > 0) - { - V1StatefulSet minio = statefulSets.Items[0]; - string name = minio.Metadata.Name; - string ns = minio.Metadata.NamespaceProperty ?? "default"; - string endpoint = $"{name}.{ns}.svc:9000"; - - return new DiscoveredMinioInstance( - Name: name, - Namespace: ns, - Endpoint: endpoint, - ConsoleEndpoint: null, - CredentialsSecret: $"{name}-secret", - TotalCapacity: null, - UsedCapacity: null, - Buckets: new List()); - } - - return null; - } -} diff --git a/src/EntKube.Provisioning/Features/MinioInstances/MinioInstanceEndpoints.cs b/src/EntKube.Provisioning/Features/MinioInstances/MinioInstanceEndpoints.cs deleted file mode 100644 index 2286033..0000000 --- a/src/EntKube.Provisioning/Features/MinioInstances/MinioInstanceEndpoints.cs +++ /dev/null @@ -1,109 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.MinioInstances; - -/// -/// Maps MinIO instance endpoints. MinIO is adoption-only — no provisioning. -/// The platform discovers existing MinIO deployments and registers them so -/// CNPG clusters can reference them for backup configuration. -/// -public static class MinioInstanceEndpoints -{ - public static void Map(WebApplication app) - { - RouteGroupBuilder group = app.MapGroup("/api/minio-instances"); - - // POST /api/minio-instances/adopt — Discover and adopt an existing MinIO tenant. - - group.MapPost("/adopt", async ( - [FromServices] AdoptMinioInstanceHandler handler, - [FromBody] AdoptMinioApiRequest body, - CancellationToken ct) => - { - AdoptMinioInstanceRequest request = new(body.ClusterId, body.KubeConfig, body.ContextName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Ok(new { id = result.Value }); - }); - - // GET /api/minio-instances — List all adopted MinIO instances. - - group.MapGet("/", async ( - [FromServices] IMinioInstanceRepository repository, - CancellationToken ct) => - { - IReadOnlyList instances = await repository.GetAllAsync(ct); - - List summaries = instances.Select(i => new MinioInstanceSummary( - Id: i.Id, - ClusterId: i.ClusterId, - Name: i.Name, - Namespace: i.Namespace, - Endpoint: i.Endpoint, - ConsoleEndpoint: i.ConsoleEndpoint, - Status: i.Status.ToString(), - TotalCapacity: i.TotalCapacity, - UsedCapacity: i.UsedCapacity, - BucketCount: i.Buckets.Count, - AdoptedAt: i.AdoptedAt)).ToList(); - - return Results.Ok(summaries); - }); - - // GET /api/minio-instances/{id} — Get a specific adopted MinIO instance. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IMinioInstanceRepository repository, - CancellationToken ct) => - { - MinioInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(new { error = "MinIO instance not found." }); - } - - return Results.Ok(new - { - instance.Id, - instance.ClusterId, - instance.Name, - instance.Namespace, - instance.Endpoint, - instance.ConsoleEndpoint, - instance.CredentialsSecret, - instance.TotalCapacity, - instance.UsedCapacity, - Status = instance.Status.ToString(), - instance.StatusMessage, - instance.Buckets, - instance.AdoptedAt, - instance.LastReconcileAt - }); - }); - } -} - -public record AdoptMinioApiRequest(Guid ClusterId, string KubeConfig, string ContextName); - -public record MinioInstanceSummary( - Guid Id, - Guid ClusterId, - string Name, - string Namespace, - string Endpoint, - string? ConsoleEndpoint, - string Status, - string? TotalCapacity, - string? UsedCapacity, - int BucketCount, - DateTimeOffset AdoptedAt); diff --git a/src/EntKube.Provisioning/Features/MinioTenants/AdoptMinioTenants/AdoptMinioTenantsHandler.cs b/src/EntKube.Provisioning/Features/MinioTenants/AdoptMinioTenants/AdoptMinioTenantsHandler.cs deleted file mode 100644 index 4e278c2..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/AdoptMinioTenants/AdoptMinioTenantsHandler.cs +++ /dev/null @@ -1,114 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants; - -/// -/// Handles adoption of existing MinIO tenants. When a Kubernetes cluster already has -/// MinIO Tenant CRDs deployed (either by Terraform, Helm, or manual kubectl apply), -/// this handler discovers them and imports them into the platform's domain so they -/// appear in the UI and can be managed. -/// -/// The flow mirrors CNPG cluster adoption: -/// 1. Call the K8s client to discover all MinIO Tenant CRDs across all namespaces -/// 2. Check which tenants are already adopted (de-duplicate by name + namespace) -/// 3. For new tenants, create domain aggregates in Running state -/// 4. For already-adopted tenants, return the existing ID -/// 5. Persist all new tenants -/// -public class AdoptMinioTenantsHandler -{ - private readonly IMinioTenantRepository repository; - private readonly IMinioTenantClient tenantClient; - - public AdoptMinioTenantsHandler(IMinioTenantRepository repository, IMinioTenantClient tenantClient) - { - this.repository = repository; - this.tenantClient = tenantClient; - } - - public async Task>> HandleAsync(AdoptMinioTenantsRequest request, CancellationToken ct = default) - { - // Discover what MinIO tenants are running on the cluster. The K8s client - // reads all Tenant CRDs (minio.min.io/v2) across all namespaces and - // extracts their pool configuration and current state. - - List discovered = await tenantClient.DiscoverTenantsAsync( - request.KubeConfig, request.ContextName, ct); - - // Load existing adopted tenants for this cluster so we can de-duplicate. - // If a tenant with the same name and namespace already exists in our - // repository, we skip it rather than creating a duplicate. - - IReadOnlyList existingTenants = - await repository.GetByClusterIdAsync(request.ClusterId, ct); - - List adoptedIds = new(); - - foreach (DiscoveredMinioTenant disc in discovered) - { - // Check if this tenant was already adopted by matching name + namespace. - - MinioTenant? alreadyAdopted = existingTenants.FirstOrDefault( - t => t.Name == disc.Name && t.Namespace == disc.Namespace); - - if (alreadyAdopted is not null) - { - // Already tracked. Reconcile the status in case the live state - // has changed since the last adoption or health check. This fixes - // tenants stuck in "Degraded" from a stale operator currentState. - - if (disc.CurrentState is "Initialized" or "Running" or null) - { - if (alreadyAdopted.Status != MinioTenantStatus.Running) - { - alreadyAdopted.MarkRunning(); - await repository.UpdateAsync(alreadyAdopted, ct); - } - } - - adoptedIds.Add(alreadyAdopted.Id); - continue; - } - - // Convert discovered pool info into domain pool objects. - - List pools = disc.Pools - .Select(p => new MinioTenantPool(p.Servers, p.VolumesPerServer, p.StorageSize, p.StorageClass)) - .ToList(); - - // Create the domain aggregate. Adopted tenants start as Running because - // they were already serving traffic when we discovered them. - - MinioTenant tenant = MinioTenant.Adopt( - clusterId: request.ClusterId, - name: disc.Name, - ns: disc.Namespace, - pools: pools); - - // If the operator reports a non-healthy state, mark the tenant degraded - // so the UI shows a warning. Healthy states are "Initialized" and null. - - if (disc.CurrentState is not null && - disc.CurrentState != "Initialized" && - disc.CurrentState != "Running") - { - tenant.MarkDegraded($"Operator state: {disc.CurrentState}"); - } - - await repository.AddAsync(tenant, ct); - adoptedIds.Add(tenant.Id); - } - - return Result.Success(adoptedIds); - } -} - -/// -/// Request to discover and adopt existing MinIO tenants on a Kubernetes cluster. -/// The kubeConfig and contextName are injected by the BFF — the frontend only sends clusterId. -/// -public record AdoptMinioTenantsRequest( - Guid ClusterId, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/MinioTenants/ConfigureMinioTenant/ConfigureMinioTenantHandler.cs b/src/EntKube.Provisioning/Features/MinioTenants/ConfigureMinioTenant/ConfigureMinioTenantHandler.cs deleted file mode 100644 index 2939c57..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/ConfigureMinioTenant/ConfigureMinioTenantHandler.cs +++ /dev/null @@ -1,84 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant; - -/// -/// Handles reconfiguring an existing MinIO tenant — typically scaling the pools -/// (adding servers, changing volume count or size). The flow is: -/// 1. Load the existing tenant aggregate -/// 2. Update pool configuration (domain transitions to Provisioning) -/// 3. Re-apply the Tenant CRD to the cluster -/// 4. Persist the updated aggregate -/// -/// The MinIO Operator handles the actual scaling — it creates new StatefulSet -/// replicas, provisions PVCs, and rebalances data. -/// -public class ConfigureMinioTenantHandler -{ - private readonly IMinioTenantRepository repository; - private readonly IMinioTenantClient tenantClient; - - public ConfigureMinioTenantHandler(IMinioTenantRepository repository, IMinioTenantClient tenantClient) - { - this.repository = repository; - this.tenantClient = tenantClient; - } - - public async Task HandleAsync(ConfigureMinioTenantRequest request, CancellationToken ct = default) - { - // Load the existing tenant. - - MinioTenant? tenant = await repository.GetByIdAsync(request.TenantId, ct); - - if (tenant is null) - { - return Result.Failure("MinIO tenant not found."); - } - - // Update the pool configuration. The domain will validate and transition - // the status back to Provisioning until the operator confirms. - - List newPools = request.Pools - .Select(p => new MinioTenantPool( - p.Servers, p.VolumesPerServer, p.StorageSize, p.StorageClass, - p.CpuRequest, p.CpuLimit, p.MemoryRequest, p.MemoryLimit)) - .ToList(); - - try - { - tenant.UpdatePools(newPools); - } - catch (ArgumentException ex) - { - return Result.Failure(ex.Message); - } - - // Re-apply the updated Tenant CRD to the cluster. - - try - { - await tenantClient.ApplyTenantAsync(request.KubeConfig, request.ContextName, tenant, ct); - } - catch (Exception ex) - { - return Result.Failure($"Failed to apply updated tenant to cluster: {ex.Message}"); - } - - // Persist the updated state. - - await repository.UpdateAsync(tenant, ct); - - return Result.Success(); - } -} - -/// -/// API request to reconfigure a MinIO tenant's pool settings. -/// -public record ConfigureMinioTenantRequest( - Guid TenantId, - string KubeConfig, - string ContextName, - List Pools); diff --git a/src/EntKube.Provisioning/Features/MinioTenants/CreateMinioTenant/CreateMinioTenantHandler.cs b/src/EntKube.Provisioning/Features/MinioTenants/CreateMinioTenant/CreateMinioTenantHandler.cs deleted file mode 100644 index ca71219..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/CreateMinioTenant/CreateMinioTenantHandler.cs +++ /dev/null @@ -1,99 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant; - -/// -/// Handles creating a new MinIO tenant on a Kubernetes cluster. The flow is: -/// 1. Validate inputs (domain enforces invariants) -/// 2. Create the MinioTenant aggregate (starts in Provisioning state) -/// 3. Apply the Tenant CRD to the cluster via the MinIO Operator -/// 4. Persist the aggregate -/// -/// If the K8s apply fails, we don't persist — the tenant doesn't exist. -/// If it succeeds, the operator will asynchronously create pods, PVCs, etc. -/// A background reconciler will later update the status to Running. -/// -public class CreateMinioTenantHandler -{ - private readonly IMinioTenantRepository repository; - private readonly IMinioTenantClient tenantClient; - - public CreateMinioTenantHandler(IMinioTenantRepository repository, IMinioTenantClient tenantClient) - { - this.repository = repository; - this.tenantClient = tenantClient; - } - - public async Task> HandleAsync(CreateMinioTenantRequest request, CancellationToken ct = default) - { - // Convert the API pool specs into domain pool objects. - - List pools = request.Pools - .Select(p => new MinioTenantPool( - p.Servers, p.VolumesPerServer, p.StorageSize, p.StorageClass, - p.CpuRequest, p.CpuLimit, p.MemoryRequest, p.MemoryLimit)) - .ToList(); - - // Let the domain validate and create the aggregate. If inputs are invalid, - // the domain throws — we catch and return a failure result. - - MinioTenant tenant; - - try - { - tenant = MinioTenant.Create( - clusterId: request.ClusterId, - name: request.Name, - ns: request.Namespace, - pools: pools); - } - catch (ArgumentException ex) - { - return Result.Failure(ex.Message); - } - - // Apply the Tenant CRD to the Kubernetes cluster. The MinIO Operator - // watches for these and starts creating the storage infrastructure. - - try - { - await tenantClient.ApplyTenantAsync(request.KubeConfig, request.ContextName, tenant, ct); - } - catch (Exception ex) - { - return Result.Failure($"Failed to apply MinIO Tenant to cluster: {ex.Message}"); - } - - // Persist the domain aggregate so we can track this tenant going forward. - - await repository.AddAsync(tenant, ct); - - return Result.Success(tenant.Id); - } -} - -/// -/// API request to create a new MinIO tenant. -/// -public record CreateMinioTenantRequest( - Guid ClusterId, - string KubeConfig, - string ContextName, - string Name, - string Namespace, - List Pools); - -/// -/// Pool configuration from the API. Maps to MinioTenantPool in the domain. -/// Resource fields are optional — when omitted, Kubernetes uses default limits. -/// -public record PoolSpec( - int Servers, - int VolumesPerServer, - string StorageSize, - string StorageClass, - string? CpuRequest = null, - string? CpuLimit = null, - string? MemoryRequest = null, - string? MemoryLimit = null); diff --git a/src/EntKube.Provisioning/Features/MinioTenants/DeleteMinioTenant/DeleteMinioTenantHandler.cs b/src/EntKube.Provisioning/Features/MinioTenants/DeleteMinioTenant/DeleteMinioTenantHandler.cs deleted file mode 100644 index e189d37..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/DeleteMinioTenant/DeleteMinioTenantHandler.cs +++ /dev/null @@ -1,66 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant; - -/// -/// Handles deleting a MinIO tenant from a Kubernetes cluster. The flow is: -/// 1. Load the existing tenant aggregate -/// 2. Delete the Tenant CRD from the cluster (operator cleans up resources) -/// 3. Mark the aggregate as Decommissioned -/// 4. Persist the updated state -/// -/// We keep the aggregate in Decommissioned state rather than deleting from our store -/// for audit trail purposes. The actual K8s resources (StatefulSets, PVCs) are deleted -/// by the MinIO Operator when it sees the Tenant CRD removed. -/// -public class DeleteMinioTenantHandler -{ - private readonly IMinioTenantRepository repository; - private readonly IMinioTenantClient tenantClient; - - public DeleteMinioTenantHandler(IMinioTenantRepository repository, IMinioTenantClient tenantClient) - { - this.repository = repository; - this.tenantClient = tenantClient; - } - - public async Task HandleAsync(DeleteMinioTenantRequest request, CancellationToken ct = default) - { - // Load the existing tenant. - - MinioTenant? tenant = await repository.GetByIdAsync(request.TenantId, ct); - - if (tenant is null) - { - return Result.Failure("MinIO tenant not found."); - } - - // Delete the Tenant CRD from the cluster. The operator handles teardown. - - try - { - await tenantClient.DeleteTenantAsync( - request.KubeConfig, request.ContextName, tenant.Name, tenant.Namespace, ct); - } - catch (Exception ex) - { - return Result.Failure($"Failed to delete MinIO Tenant from cluster: {ex.Message}"); - } - - // Mark as decommissioned and persist. - - tenant.Decommission(); - await repository.UpdateAsync(tenant, ct); - - return Result.Success(); - } -} - -/// -/// API request to delete a MinIO tenant. -/// -public record DeleteMinioTenantRequest( - Guid TenantId, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/MinioTenants/IMinioTenantClient.cs b/src/EntKube.Provisioning/Features/MinioTenants/IMinioTenantClient.cs deleted file mode 100644 index e05ca54..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/IMinioTenantClient.cs +++ /dev/null @@ -1,93 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.MinioTenants; - -/// -/// Defines operations against the Kubernetes API for MinIO Tenant CRDs. -/// The MinIO Operator watches for these CRDs and creates the underlying -/// StatefulSets, PVCs, and Services to bring the tenant online. -/// -public interface IMinioTenantClient -{ - /// - /// Applies (creates or updates) a MinIO Tenant CRD on the cluster. - /// This is the equivalent of kubectl apply -f tenant.yaml. - /// - Task ApplyTenantAsync(string kubeConfig, string contextName, MinioTenant tenant, CancellationToken ct = default); - - /// - /// Deletes a MinIO Tenant CRD from the cluster. The operator will tear down - /// all associated resources (StatefulSets, PVCs, Services). - /// - Task DeleteTenantAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Checks the status of a MinIO Tenant on the cluster by reading the CRD's - /// status field. Returns the current phase (e.g., "Initialized", "Provisioning", etc.) - /// - Task GetTenantStatusAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Discovers all MinIO Tenant CRDs across all namespaces on the cluster. - /// Returns metadata and pool configuration for each tenant found. - /// Used by the adoption flow to import pre-existing tenants into the platform. - /// - Task> DiscoverTenantsAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Reads live health details from a MinIO Tenant CRD and its pods. - /// Includes current state, pod readiness, capacity, and drive status. - /// - Task GetTenantHealthAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Lists all buckets in a MinIO tenant by running mc ls via a K8s Job - /// against the tenant's service endpoint. Returns bucket names. - /// - Task> ListBucketsAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Creates a new bucket on the MinIO tenant by running mc mb via a K8s Job. - /// - Task CreateBucketAsync(string kubeConfig, string contextName, string name, string ns, string bucketName, CancellationToken ct = default); - - /// - /// Deletes a bucket from the MinIO tenant by running mc rb via a K8s Job. - /// Only removes empty buckets unless forced. - /// - Task DeleteBucketAsync(string kubeConfig, string contextName, string name, string ns, string bucketName, CancellationToken ct = default); -} - -/// -/// Metadata about a MinIO Tenant discovered on a Kubernetes cluster. -/// Contains the pool configuration and current operator state. -/// -public record DiscoveredMinioTenant( - string Name, - string Namespace, - List Pools, - string? CurrentState, - List Buckets); - -/// -/// Pool configuration discovered from an existing MinIO Tenant CRD. -/// -public record DiscoveredMinioPool( - int Servers, - int VolumesPerServer, - string StorageSize, - string StorageClass); - -/// -/// Live health information for a MinIO Tenant read from K8s. -/// -public record MinioTenantHealth( - string CurrentState, - int ReadyPods, - int TotalPods, - string? TotalCapacity, - string? UsedCapacity, - int DriveCount, - int DrivesOnline, - int DrivesOffline, - List Buckets); diff --git a/src/EntKube.Provisioning/Features/MinioTenants/IMinioTenantRepository.cs b/src/EntKube.Provisioning/Features/MinioTenants/IMinioTenantRepository.cs deleted file mode 100644 index 0a69769..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/IMinioTenantRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.MinioTenants; - -/// -/// Defines persistence operations for MinIO tenants. -/// -public interface IMinioTenantRepository -{ - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); - Task AddAsync(MinioTenant tenant, CancellationToken ct = default); - Task UpdateAsync(MinioTenant tenant, CancellationToken ct = default); -} diff --git a/src/EntKube.Provisioning/Features/MinioTenants/KubernetesMinioTenantClient.cs b/src/EntKube.Provisioning/Features/MinioTenants/KubernetesMinioTenantClient.cs deleted file mode 100644 index a6d8265..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/KubernetesMinioTenantClient.cs +++ /dev/null @@ -1,847 +0,0 @@ -using k8s; -using k8s.Models; -using EntKube.Provisioning.Domain; -using System.Text; -using System.Text.Json; - -namespace EntKube.Provisioning.Features.MinioTenants; - -/// -/// Kubernetes API implementation of IMinioTenantClient. Manages MinIO Tenant CRDs -/// through the Kubernetes API. The MinIO Operator watches these CRDs and reconciles -/// the actual infrastructure (StatefulSets, PVCs, Services). -/// -/// Each operation builds a Kubernetes client from the provided kubeconfig and context, -/// then interacts with the minio.min.io/v2 API group. -/// -public class KubernetesMinioTenantClient : IMinioTenantClient -{ - /// - /// Applies a MinIO Tenant CRD on the cluster. Builds the CRD spec from - /// the domain model's pool configuration, then creates or patches it. - /// - public async Task ApplyTenantAsync( - string kubeConfig, string contextName, MinioTenant tenant, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - // Build the Tenant CRD object from the domain model. - - Dictionary tenantSpec = new() - { - ["apiVersion"] = "minio.min.io/v2", - ["kind"] = "Tenant", - ["metadata"] = new Dictionary - { - ["name"] = tenant.Name, - ["namespace"] = tenant.Namespace - }, - ["spec"] = BuildTenantSpec(tenant) - }; - - string json = JsonSerializer.Serialize(tenantSpec); - - try - { - // Try to get the existing tenant first. If it exists, patch it. - - await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: "minio.min.io", - version: "v2", - namespaceParameter: tenant.Namespace, - plural: "tenants", - name: tenant.Name, - cancellationToken: ct); - - // Tenant exists — patch it with the new spec. - - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: "minio.min.io", - version: "v2", - namespaceParameter: tenant.Namespace, - plural: "tenants", - name: tenant.Name, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Tenant doesn't exist yet — create it. - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: JsonSerializer.Deserialize(json), - group: "minio.min.io", - version: "v2", - namespaceParameter: tenant.Namespace, - plural: "tenants", - cancellationToken: ct); - } - } - - /// - /// Deletes a MinIO Tenant CRD. The operator will tear down all associated - /// resources (StatefulSets, PVCs, Services) when the CRD is removed. - /// - public async Task DeleteTenantAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: "minio.min.io", - version: "v2", - namespaceParameter: ns, - plural: "tenants", - name: name, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Already deleted — nothing to do. - } - } - - /// - /// Reads the status phase from a MinIO Tenant CRD. The operator sets this - /// field as it reconciles (e.g., "Initialized", "Provisioning", "Running"). - /// - public async Task GetTenantStatusAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - JsonElement tenant = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: "minio.min.io", - version: "v2", - namespaceParameter: ns, - plural: "tenants", - name: name, - cancellationToken: ct); - - if (tenant.TryGetProperty("status", out JsonElement status) && - status.TryGetProperty("currentState", out JsonElement state)) - { - return state.GetString(); - } - - return null; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return null; - } - } - - /// - /// Discovers all MinIO Tenant CRDs across all namespaces. Reads pool - /// configuration from the spec and current state from the status field. - /// - public async Task> DiscoverTenantsAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - List discovered = new(); - - try - { - JsonElement response = await client.CustomObjects.ListClusterCustomObjectAsync( - group: "minio.min.io", - version: "v2", - plural: "tenants", - cancellationToken: ct); - - if (!response.TryGetProperty("items", out JsonElement items)) - { - return discovered; - } - - foreach (JsonElement tenant in items.EnumerateArray()) - { - JsonElement metadata = tenant.GetProperty("metadata"); - string name = metadata.GetProperty("name").GetString() ?? "unknown"; - string ns = metadata.GetProperty("namespace").GetString() ?? "default"; - - // Extract pool configuration from spec.pools. - - List pools = new(); - - if (tenant.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("pools", out JsonElement poolsElement)) - { - foreach (JsonElement pool in poolsElement.EnumerateArray()) - { - int servers = pool.TryGetProperty("servers", out JsonElement s) ? s.GetInt32() : 1; - int volumes = pool.TryGetProperty("volumesPerServer", out JsonElement v) ? v.GetInt32() : 1; - string storageSize = "10Gi"; - string storageClass = "default"; - - if (pool.TryGetProperty("volumeClaimTemplate", out JsonElement vct) && - vct.TryGetProperty("spec", out JsonElement vctSpec) && - vctSpec.TryGetProperty("resources", out JsonElement res) && - res.TryGetProperty("requests", out JsonElement req) && - req.TryGetProperty("storage", out JsonElement stor)) - { - storageSize = stor.GetString() ?? "10Gi"; - } - - if (vct.TryGetProperty("spec", out JsonElement vctSpec2) && - vctSpec2.TryGetProperty("storageClassName", out JsonElement sc)) - { - storageClass = sc.GetString() ?? "default"; - } - - pools.Add(new DiscoveredMinioPool(servers, volumes, storageSize, storageClass)); - } - } - - // Derive the most accurate state from all available status signals. - // The operator's currentState can be stale (stuck on "Provisioning - // MinIO Statefulset" even when the tenant is fully operational), so - // we check healthStatus and per-pool states as higher-priority signals. - - string? currentState = null; - - if (tenant.TryGetProperty("status", out JsonElement status)) - { - if (status.TryGetProperty("currentState", out JsonElement state)) - { - currentState = state.GetString(); - } - - // healthStatus ("green"/"yellow"/"red") is the most reliable signal. - - if (status.TryGetProperty("healthStatus", out JsonElement healthEl)) - { - string? healthStatus = healthEl.GetString(); - - currentState = healthStatus?.ToLowerInvariant() switch - { - "green" => "Initialized", - "yellow" => "Degraded", - "red" => "Unhealthy", - _ => currentState - }; - } - else if (status.TryGetProperty("pools", out JsonElement poolsStatus) && - poolsStatus.ValueKind == JsonValueKind.Array) - { - // Fall back to per-pool state. When all pools report - // "PoolInitialized", the tenant is fully operational. - - bool allInitialized = true; - bool hasPools = false; - - foreach (JsonElement poolEntry in poolsStatus.EnumerateArray()) - { - hasPools = true; - - if (!poolEntry.TryGetProperty("state", out JsonElement poolState) || - !string.Equals(poolState.GetString(), "PoolInitialized", StringComparison.OrdinalIgnoreCase)) - { - allInitialized = false; - break; - } - } - - if (hasPools && allInitialized) - { - currentState = "Initialized"; - } - } - } - - discovered.Add(new DiscoveredMinioTenant(name, ns, pools, currentState, new List())); - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // MinIO Tenant CRD not installed — no tenants to discover. - } - - return discovered; - } - - /// - /// Reads live health details from a MinIO Tenant CRD and its pods. - /// Gathers pod readiness, drive status, and capacity from the status field. - /// - public async Task GetTenantHealthAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - JsonElement tenant = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: "minio.min.io", - version: "v2", - namespaceParameter: ns, - plural: "tenants", - name: name, - cancellationToken: ct); - - // Read status fields. The operator's currentState can be stale - // (e.g. stuck on "Provisioning MinIO Statefulset"), so we check - // multiple signals: healthStatus > pool states > pod readiness. - - string currentState = "Unknown"; - int driveCount = 0; - int drivesOnline = 0; - int drivesOffline = 0; - string? totalCapacity = null; - string? usedCapacity = null; - bool allPoolsInitialized = false; - - if (tenant.TryGetProperty("status", out JsonElement status)) - { - if (status.TryGetProperty("currentState", out JsonElement state)) - { - currentState = state.GetString() ?? "Unknown"; - } - - // healthStatus ("green"/"yellow"/"red") is the most reliable signal - // when present (MinIO Operator v5+). - - if (status.TryGetProperty("healthStatus", out JsonElement healthEl)) - { - string? healthStatus = healthEl.GetString(); - - currentState = healthStatus?.ToLowerInvariant() switch - { - "green" => "Initialized", - "yellow" => "Degraded", - "red" => "Unhealthy", - _ => currentState - }; - } - else if (status.TryGetProperty("pools", out JsonElement poolsStatus) && - poolsStatus.ValueKind == JsonValueKind.Array) - { - // Fall back to per-pool state. When all pools report - // "PoolInitialized", the tenant is fully operational. - - bool hasPoolEntries = false; - allPoolsInitialized = true; - - foreach (JsonElement poolEntry in poolsStatus.EnumerateArray()) - { - hasPoolEntries = true; - - if (!poolEntry.TryGetProperty("state", out JsonElement poolState) || - !string.Equals(poolState.GetString(), "PoolInitialized", StringComparison.OrdinalIgnoreCase)) - { - allPoolsInitialized = false; - break; - } - } - - if (hasPoolEntries && allPoolsInitialized) - { - currentState = "Initialized"; - } - } - - // Read drive counts from status if the operator reports them. - - if (status.TryGetProperty("drivesOnline", out JsonElement online)) - { - drivesOnline = online.TryGetInt32(out int o) ? o : 0; - } - - if (status.TryGetProperty("drivesOffline", out JsonElement offline)) - { - drivesOffline = offline.TryGetInt32(out int off) ? off : 0; - } - - driveCount = drivesOnline + drivesOffline; - - if (status.TryGetProperty("usage", out JsonElement usage)) - { - if (usage.TryGetProperty("capacity", out JsonElement cap)) - { - totalCapacity = cap.GetString(); - } - - if (usage.TryGetProperty("usage", out JsonElement used)) - { - usedCapacity = used.GetString(); - } - } - } - - // If the operator didn't report drive counts, calculate from the spec. - - if (driveCount == 0 && tenant.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("pools", out JsonElement poolsSpec)) - { - foreach (JsonElement pool in poolsSpec.EnumerateArray()) - { - int servers = pool.TryGetProperty("servers", out JsonElement s) ? s.GetInt32() : 1; - int volumes = pool.TryGetProperty("volumesPerServer", out JsonElement v) ? v.GetInt32() : 1; - driveCount += servers * volumes; - } - } - - // Count pod readiness from the tenant's StatefulSet pods. - - V1PodList pods = await client.CoreV1.ListNamespacedPodAsync( - namespaceParameter: ns, - labelSelector: $"v1.min.io/tenant={name}", - cancellationToken: ct); - - int totalPods = pods.Items.Count; - int readyPods = pods.Items.Count(p => - p.Status?.ContainerStatuses?.All(c => c.Ready) == true); - - // If operator didn't report drive counts, estimate from pod readiness. - - if (drivesOnline == 0 && readyPods > 0 && driveCount > 0 && totalPods > 0) - { - drivesOnline = (int)Math.Round((double)readyPods / totalPods * driveCount); - drivesOffline = driveCount - drivesOnline; - } - - // Final safety net: if all pods are ready, the tenant IS serving traffic - // regardless of what the CRD status reports. - - if (readyPods > 0 && readyPods == totalPods && currentState != "Initialized") - { - currentState = "Initialized"; - } - - return new MinioTenantHealth( - currentState, readyPods, totalPods, totalCapacity, usedCapacity, - driveCount, drivesOnline, drivesOffline, new List()); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return null; - } - } - - /// - /// Lists buckets by running mc ls via a K8s Job in the tenant's namespace. - /// The credential secret is volume-mounted into the Job pod. - /// - public async Task> ListBucketsAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - string endpoint = $"https://{name}-hl.{ns}.svc.cluster.local:9000"; - string secretName = await GetCredentialSecretNameAsync(client, name, ns, ct); - - string? output = await RunMcJobAsync(client, ns, name, secretName, endpoint, "mc ls myminio --insecure", ct); - - if (string.IsNullOrWhiteSpace(output)) - { - return new List(); - } - - // If the output contains only error lines and no bucket entries, - // surface the error so the user sees what went wrong. - - List errorLines = output - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(line => line.StartsWith("ERROR") || line.StartsWith("mc:")) - .ToList(); - - if (errorLines.Count > 0 && !output.Contains('/')) - { - throw new InvalidOperationException($"mc ls failed: {string.Join("; ", errorLines)}"); - } - - // mc ls output format: "[2024-01-15 10:30:00 UTC] 0B bucket-name/" - // We take the last token from each line and trim the trailing slash. - - List buckets = output - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("Added") && !line.StartsWith("ERROR") && !line.StartsWith("mc:")) - .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "") - .Where(n => !string.IsNullOrWhiteSpace(n)) - .Select(n => n.TrimEnd('/')) - .ToList(); - - return buckets; - } - - /// - /// Creates a bucket by running mc mb via a K8s Job. - /// - public async Task CreateBucketAsync( - string kubeConfig, string contextName, string name, string ns, string bucketName, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - string endpoint = $"https://{name}-hl.{ns}.svc.cluster.local:9000"; - string secretName = await GetCredentialSecretNameAsync(client, name, ns, ct); - - await RunMcJobAsync(client, ns, name, secretName, endpoint, $"mc mb myminio/{bucketName} --insecure", ct); - } - - /// - /// Deletes a bucket by first removing all objects (including versioned objects) - /// and then running mc rb via a K8s Job. This handles buckets with versioning - /// or object-lock enabled that mc rb --force alone cannot remove. - /// - public async Task DeleteBucketAsync( - string kubeConfig, string contextName, string name, string ns, string bucketName, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - string endpoint = $"https://{name}-hl.{ns}.svc.cluster.local:9000"; - string secretName = await GetCredentialSecretNameAsync(client, name, ns, ct); - - string command = $"mc rm --recursive --force --versions myminio/{bucketName} --insecure; mc rb myminio/{bucketName} --force --insecure"; - await RunMcJobAsync(client, ns, name, secretName, endpoint, command, ct); - } - - /// - /// Runs a MinIO Client (mc) command via a K8s Job in the tenant's namespace. - /// The credential secret is mounted as a volume so we don't need API-level - /// secret-read permissions. Uses POSIX shell parameter expansion to extract - /// credentials (the minio/mc container has no sed/grep/awk). - /// - private static async Task RunMcJobAsync( - Kubernetes client, string ns, string tenantName, - string secretName, string endpoint, string mcCommand, CancellationToken ct) - { - string jobName = $"entkube-mc-{tenantName}-{Guid.NewGuid().ToString("N")[..8]}"; - - // Build a shell script that extracts credentials from config.env using - // pure POSIX parameter expansion (no external commands needed). - - string script = $$""" - if [ -f /tmp/creds/config.env ]; then - CONTENT=$(cat /tmp/creds/config.env) - TEMP=${CONTENT#*MINIO_ROOT_USER=\"} - MINIO_ROOT_USER=${TEMP%%\"*} - TEMP=${CONTENT#*MINIO_ROOT_PASSWORD=\"} - MINIO_ROOT_PASSWORD=${TEMP%%\"*} - if [ -z "$MINIO_ROOT_USER" ] || [ -z "$MINIO_ROOT_PASSWORD" ]; then - echo "ERROR: Could not parse credentials from config.env" - cat /tmp/creds/config.env - exit 1 - fi - mc alias set myminio {{endpoint}} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --insecure 2>&1 - if [ $? -ne 0 ]; then - echo "ERROR: mc alias set failed" - exit 1 - fi - elif [ -f /tmp/creds/accesskey ] && [ -f /tmp/creds/secretkey ]; then - mc alias set myminio {{endpoint}} "$(cat /tmp/creds/accesskey)" "$(cat /tmp/creds/secretkey)" --insecure 2>&1 - if [ $? -ne 0 ]; then - echo "ERROR: mc alias set failed" - exit 1 - fi - else - echo "ERROR: No credentials found in mounted secret" - exit 1 - fi - {{mcCommand}} 2>&1 - """; - - V1Job job = new() - { - Metadata = new V1ObjectMeta - { - Name = jobName, - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube/operation"] = "mc-command" - } - }, - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 60, - BackoffLimit = 0, - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta - { - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["v1.min.io/tenant"] = tenantName - } - }, - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Volumes = new List - { - new() - { - Name = "minio-creds", - Secret = new V1SecretVolumeSource - { - SecretName = secretName, - Optional = false - } - } - }, - Containers = new List - { - new() - { - Name = "mc", - Image = "minio/mc:latest", - Command = new List { "/bin/sh", "-c" }, - Args = new List { script }, - VolumeMounts = new List - { - new() - { - Name = "minio-creds", - MountPath = "/tmp/creds", - ReadOnlyProperty = true - } - } - } - } - } - } - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - // Wait for the Job to complete (up to 30 seconds). - - for (int i = 0; i < 30; i++) - { - await Task.Delay(1000, ct); - - V1Job jobStatus = await client.BatchV1.ReadNamespacedJobAsync(jobName, ns, cancellationToken: ct); - - if (jobStatus.Status?.Succeeded > 0) - { - V1PodList jobPods = await client.CoreV1.ListNamespacedPodAsync( - ns, labelSelector: $"job-name={jobName}", cancellationToken: ct); - - if (jobPods.Items.Count > 0) - { - Stream logStream = await client.CoreV1.ReadNamespacedPodLogAsync( - jobPods.Items[0].Metadata.Name, ns, cancellationToken: ct); - - using StreamReader reader = new(logStream); - string logs = await reader.ReadToEndAsync(ct); - - try { await client.BatchV1.DeleteNamespacedJobAsync(jobName, ns, propagationPolicy: "Background", cancellationToken: ct); } catch { } - - return logs; - } - - break; - } - - if (jobStatus.Status?.Failed > 0) - { - string errorLogs = ""; - V1PodList jobPods = await client.CoreV1.ListNamespacedPodAsync( - ns, labelSelector: $"job-name={jobName}", cancellationToken: ct); - - if (jobPods.Items.Count > 0) - { - try - { - Stream errorStream = await client.CoreV1.ReadNamespacedPodLogAsync( - jobPods.Items[0].Metadata.Name, ns, cancellationToken: ct); - using StreamReader errorReader = new(errorStream); - errorLogs = await errorReader.ReadToEndAsync(ct); - } - catch { } - } - - try { await client.BatchV1.DeleteNamespacedJobAsync(jobName, ns, propagationPolicy: "Background", cancellationToken: ct); } catch { } - - throw new InvalidOperationException($"mc command failed: {errorLogs}"); - } - } - - try { await client.BatchV1.DeleteNamespacedJobAsync(jobName, ns, propagationPolicy: "Background", cancellationToken: ct); } catch { } - - return null; - } - - /// - /// Reads the Tenant CRD to discover which K8s secret holds root credentials. - /// Checks spec.configuration.name (v5+/v6) and spec.credsSecret.name (v4). - /// - private static async Task GetCredentialSecretNameAsync( - Kubernetes client, string tenantName, string ns, CancellationToken ct) - { - try - { - JsonElement tenant = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: "minio.min.io", version: "v2", namespaceParameter: ns, - plural: "tenants", name: tenantName, cancellationToken: ct); - - if (tenant.TryGetProperty("spec", out JsonElement spec)) - { - if (spec.TryGetProperty("configuration", out JsonElement configuration) && - configuration.TryGetProperty("name", out JsonElement configNameEl)) - { - string? configName = configNameEl.GetString(); - if (!string.IsNullOrWhiteSpace(configName)) { return configName; } - } - - if (spec.TryGetProperty("credsSecret", out JsonElement credsSecret) && - credsSecret.TryGetProperty("name", out JsonElement credsNameEl)) - { - string? credsName = credsNameEl.GetString(); - if (!string.IsNullOrWhiteSpace(credsName)) { return credsName; } - } - } - } - catch { } - - return $"{tenantName}-env-configuration"; - } - - // ─── Helpers ───────────────────────────────────────────────────────── - - /// - /// Parses MinIO root credentials from a config.env string. MinIO Operator v5+ - /// stores credentials as export statements in a single Kubernetes Secret key. - /// Supports quoted (single or double), unquoted, and single-line formats. - /// Returns null if either MINIO_ROOT_USER or MINIO_ROOT_PASSWORD is missing. - /// - public static (string accessKey, string secretKey)? ParseConfigEnvCredentials(string configEnv) - { - if (string.IsNullOrWhiteSpace(configEnv)) - { - return null; - } - - string? user = null; - string? password = null; - - // Split on newlines first, but also handle single-line format where - // exports are separated by spaces (MinIO Operator v6 edge case). - // We use regex to find each export KEY=VALUE pair. - - System.Text.RegularExpressions.MatchCollection matches = - System.Text.RegularExpressions.Regex.Matches( - configEnv, - @"export\s+(MINIO_ROOT_USER|MINIO_ROOT_PASSWORD)\s*=\s*(?:[""']([^""']*)[""']|(\S+))"); - - foreach (System.Text.RegularExpressions.Match match in matches) - { - string key = match.Groups[1].Value; - string value = match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Value; - - if (key == "MINIO_ROOT_USER") - { - user = value; - } - else if (key == "MINIO_ROOT_PASSWORD") - { - password = value; - } - } - - if (user is null || password is null) - { - return null; - } - - return (user, password); - } - - private static Kubernetes BuildClient(string kubeConfig, string contextName) - { - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile( - new MemoryStream(Encoding.UTF8.GetBytes(kubeConfig)), - currentContext: contextName); - - return new Kubernetes(config); - } - - private static Dictionary BuildTenantSpec(MinioTenant tenant) - { - // Build pool specs from domain model. - - List> poolSpecs = new(); - - foreach (MinioTenantPool pool in tenant.Pools) - { - Dictionary poolSpec = new() - { - ["servers"] = pool.Servers, - ["volumesPerServer"] = pool.VolumesPerServer, - ["volumeClaimTemplate"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["storageClassName"] = pool.StorageClass, - ["resources"] = new Dictionary - { - ["requests"] = new Dictionary - { - ["storage"] = pool.StorageSize - } - } - } - } - }; - - // Add resource requests/limits if specified. - - Dictionary resources = new(); - Dictionary requests = new(); - Dictionary limits = new(); - - if (!string.IsNullOrEmpty(pool.CpuRequest)) - { - requests["cpu"] = pool.CpuRequest; - } - - if (!string.IsNullOrEmpty(pool.MemoryRequest)) - { - requests["memory"] = pool.MemoryRequest; - } - - if (!string.IsNullOrEmpty(pool.CpuLimit)) - { - limits["cpu"] = pool.CpuLimit; - } - - if (!string.IsNullOrEmpty(pool.MemoryLimit)) - { - limits["memory"] = pool.MemoryLimit; - } - - if (requests.Count > 0) - { - resources["requests"] = requests; - } - - if (limits.Count > 0) - { - resources["limits"] = limits; - } - - if (resources.Count > 0) - { - poolSpec["resources"] = resources; - } - - poolSpecs.Add(poolSpec); - } - - return new Dictionary - { - ["pools"] = poolSpecs, - ["credsSecret"] = new Dictionary - { - ["name"] = tenant.GetCredentialsSecretName() - } - }; - } -} diff --git a/src/EntKube.Provisioning/Features/MinioTenants/MinioTenantEndpoints.cs b/src/EntKube.Provisioning/Features/MinioTenants/MinioTenantEndpoints.cs deleted file mode 100644 index 455d57b..0000000 --- a/src/EntKube.Provisioning/Features/MinioTenants/MinioTenantEndpoints.cs +++ /dev/null @@ -1,409 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants; -using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant; -using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant; -using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.MinioTenants; - -/// -/// Maps MinIO Tenant endpoints. These manage the full lifecycle of MinIO storage -/// tenants — creating, listing, configuring, and deleting. -/// -/// Each tenant is an independent MinIO storage cluster running on a Kubernetes cluster, -/// managed by the MinIO Operator via the Tenant CRD (minio.min.io/v2). -/// -public static class MinioTenantEndpoints -{ - public static void Map(WebApplication app) - { - RouteGroupBuilder group = app.MapGroup("/api/minio-tenants"); - - // POST /api/minio-tenants — Create a new MinIO tenant on a cluster. - - group.MapPost("/", async ( - [FromServices] CreateMinioTenantHandler handler, - [FromBody] CreateMinioTenantApiRequest body, - CancellationToken ct) => - { - CreateMinioTenantRequest request = new( - ClusterId: body.ClusterId, - KubeConfig: body.KubeConfig, - ContextName: body.ContextName, - Name: body.Name, - Namespace: body.Namespace, - Pools: body.Pools); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Created($"/api/minio-tenants/{result.Value}", new { id = result.Value }); - }); - - // GET /api/minio-tenants — List all MinIO tenants. - - group.MapGet("/", async ( - [FromServices] IMinioTenantRepository repository, - CancellationToken ct) => - { - IReadOnlyList tenants = await repository.GetAllAsync(ct); - - List summaries = tenants.Select(t => new MinioTenantSummary( - Id: t.Id, - ClusterId: t.ClusterId, - Name: t.Name, - Namespace: t.Namespace, - Endpoint: t.GetEndpoint(), - ConsoleEndpoint: t.GetConsoleEndpoint(), - Status: t.Status.ToString(), - TotalCapacity: t.CalculateTotalCapacity(), - PoolCount: t.Pools.Count, - TotalServers: t.Pools.Sum(p => p.Servers), - CreatedAt: t.CreatedAt)).ToList(); - - return Results.Ok(summaries); - }); - - // GET /api/minio-tenants/cluster/{clusterId} — List tenants for a specific cluster. - - group.MapGet("/cluster/{clusterId:guid}", async ( - Guid clusterId, - [FromServices] IMinioTenantRepository repository, - CancellationToken ct) => - { - IReadOnlyList tenants = await repository.GetByClusterIdAsync(clusterId, ct); - - List summaries = tenants.Select(t => new MinioTenantSummary( - Id: t.Id, - ClusterId: t.ClusterId, - Name: t.Name, - Namespace: t.Namespace, - Endpoint: t.GetEndpoint(), - ConsoleEndpoint: t.GetConsoleEndpoint(), - Status: t.Status.ToString(), - TotalCapacity: t.CalculateTotalCapacity(), - PoolCount: t.Pools.Count, - TotalServers: t.Pools.Sum(p => p.Servers), - CreatedAt: t.CreatedAt)).ToList(); - - return Results.Ok(summaries); - }); - - // GET /api/minio-tenants/{id} — Get a specific tenant with full details. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IMinioTenantRepository repository, - CancellationToken ct) => - { - MinioTenant? tenant = await repository.GetByIdAsync(id, ct); - - if (tenant is null) - { - return Results.NotFound(new { error = "MinIO tenant not found." }); - } - - return Results.Ok(new MinioTenantDetail( - Id: tenant.Id, - ClusterId: tenant.ClusterId, - Name: tenant.Name, - Namespace: tenant.Namespace, - Endpoint: tenant.GetEndpoint(), - ConsoleEndpoint: tenant.GetConsoleEndpoint(), - CredentialsSecret: tenant.GetCredentialsSecretName(), - Status: tenant.Status.ToString(), - StatusMessage: tenant.StatusMessage, - TotalCapacity: tenant.CalculateTotalCapacity(), - Pools: tenant.Pools.Select(p => new PoolDetail( - Servers: p.Servers, - VolumesPerServer: p.VolumesPerServer, - StorageSize: p.StorageSize, - StorageClass: p.StorageClass, - CpuRequest: p.CpuRequest, - CpuLimit: p.CpuLimit, - MemoryRequest: p.MemoryRequest, - MemoryLimit: p.MemoryLimit)).ToList(), - CreatedAt: tenant.CreatedAt, - LastReconcileAt: tenant.LastReconcileAt)); - }); - - // PUT /api/minio-tenants/{id}/configure — Update pool configuration. - - group.MapPut("/{id:guid}/configure", async ( - Guid id, - [FromServices] ConfigureMinioTenantHandler handler, - [FromBody] ConfigureMinioTenantApiRequest body, - CancellationToken ct) => - { - ConfigureMinioTenantRequest request = new( - TenantId: id, - KubeConfig: body.KubeConfig, - ContextName: body.ContextName, - Pools: body.Pools); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Ok(new { message = "Tenant configuration updated." }); - }); - - // DELETE /api/minio-tenants/{id} — Delete a tenant from the cluster. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromServices] DeleteMinioTenantHandler handler, - [FromBody] DeleteMinioTenantApiRequest body, - CancellationToken ct) => - { - DeleteMinioTenantRequest request = new( - TenantId: id, - KubeConfig: body.KubeConfig, - ContextName: body.ContextName); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(new { error = result.Error }); - } - - return Results.Ok(new { message = "Tenant deleted." }); - }); - - // POST /api/minio-tenants/adopt — Discover and adopt existing MinIO tenants. - // The frontend sends clusterId, the BFF injects kubeConfig/contextName. - - group.MapPost("/adopt", async ( - [FromServices] AdoptMinioTenantsHandler handler, - [FromBody] AdoptMinioTenantsRequest request, - CancellationToken ct) => - { - Result> result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse>.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse>.Ok(result.Value!)); - }); - - // POST /api/minio-tenants/{id}/health — Live health from K8s. - // Uses POST because the kubeConfig payload is too large for query strings. - - group.MapPost("/{id:guid}/health", async ( - Guid id, - [FromBody] MinioActionApiRequest body, - [FromServices] IMinioTenantRepository repository, - [FromServices] IMinioTenantClient tenantClient, - CancellationToken ct) => - { - MinioTenant? tenant = await repository.GetByIdAsync(id, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("MinIO tenant not found.")); - } - - MinioTenantHealth? health = await tenantClient.GetTenantHealthAsync( - body.KubeConfig, body.ContextName, tenant.Name, tenant.Namespace, ct); - - if (health is null) - { - return Results.Ok(ApiResponse.Fail("Could not read tenant health.")); - } - - // Reconcile the persisted domain status with the live health from K8s. - // The operator may report the tenant as healthy even though the domain model - // is still stuck in Provisioning or Degraded from a previous state. - - if (health.CurrentState == "Initialized" && - tenant.Status != MinioTenantStatus.Running) - { - tenant.MarkRunning(); - await repository.UpdateAsync(tenant, ct); - } - else if (health.CurrentState == "Degraded" && - tenant.Status == MinioTenantStatus.Running) - { - tenant.MarkDegraded("Live health check reports degraded state."); - await repository.UpdateAsync(tenant, ct); - } - - return Results.Ok(ApiResponse.Ok(health)); - }); - - // POST /api/minio-tenants/{id}/buckets — List buckets in this tenant. - // Uses POST because the kubeConfig payload is too large for query strings. - - group.MapPost("/{id:guid}/buckets", async ( - Guid id, - [FromBody] MinioActionApiRequest body, - [FromServices] IMinioTenantRepository repository, - [FromServices] IMinioTenantClient tenantClient, - CancellationToken ct) => - { - MinioTenant? tenant = await repository.GetByIdAsync(id, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("MinIO tenant not found.")); - } - - try - { - List buckets = await tenantClient.ListBucketsAsync( - body.KubeConfig, body.ContextName, tenant.Name, tenant.Namespace, ct); - - return Results.Ok(ApiResponse>.Ok(buckets)); - } - catch (Exception ex) - { - return Results.Ok(ApiResponse>.Fail($"Failed to list buckets: {ex.Message}")); - } - }); - - // POST /api/minio-tenants/{id}/buckets/create — Create a bucket on a MinIO tenant. - // Runs an mc mb command via a K8s Job against the tenant's endpoint. - - group.MapPost("/{id:guid}/buckets/create", async ( - Guid id, - [FromBody] CreateMinioBucketApiRequest body, - [FromServices] IMinioTenantRepository repository, - [FromServices] IMinioTenantClient tenantClient, - CancellationToken ct) => - { - MinioTenant? tenant = await repository.GetByIdAsync(id, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("MinIO tenant not found.")); - } - - try - { - await tenantClient.CreateBucketAsync( - body.KubeConfig, body.ContextName, - tenant.Name, tenant.Namespace, body.BucketName, ct); - - return Results.Ok(ApiResponse.Ok($"Bucket '{body.BucketName}' created.")); - } - catch (Exception ex) - { - return Results.BadRequest(ApiResponse.Fail($"Failed to create bucket: {ex.Message}")); - } - }); - - // POST /api/minio-tenants/{id}/buckets/delete — Delete a bucket from a MinIO tenant. - - group.MapPost("/{id:guid}/buckets/delete", async ( - Guid id, - [FromBody] DeleteMinioBucketApiRequest body, - [FromServices] IMinioTenantRepository repository, - [FromServices] IMinioTenantClient tenantClient, - CancellationToken ct) => - { - MinioTenant? tenant = await repository.GetByIdAsync(id, ct); - - if (tenant is null) - { - return Results.NotFound(ApiResponse.Fail("MinIO tenant not found.")); - } - - try - { - await tenantClient.DeleteBucketAsync( - body.KubeConfig, body.ContextName, - tenant.Name, tenant.Namespace, body.BucketName, ct); - - return Results.Ok(ApiResponse.Ok($"Bucket '{body.BucketName}' deleted.")); - } - catch (Exception ex) - { - return Results.BadRequest(ApiResponse.Fail($"Failed to delete bucket: {ex.Message}")); - } - }); - } -} - -// ─── API Request/Response DTOs ────────────────────────────────────────────── - -public record CreateMinioTenantApiRequest( - Guid ClusterId, - string KubeConfig, - string ContextName, - string Name, - string Namespace, - List Pools); - -public record ConfigureMinioTenantApiRequest( - string KubeConfig, - string ContextName, - List Pools); - -public record DeleteMinioTenantApiRequest( - string KubeConfig, - string ContextName); - -public record MinioTenantSummary( - Guid Id, - Guid ClusterId, - string Name, - string Namespace, - string Endpoint, - string ConsoleEndpoint, - string Status, - string TotalCapacity, - int PoolCount, - int TotalServers, - DateTimeOffset CreatedAt); - -public record MinioTenantDetail( - Guid Id, - Guid ClusterId, - string Name, - string Namespace, - string Endpoint, - string ConsoleEndpoint, - string CredentialsSecret, - string Status, - string? StatusMessage, - string TotalCapacity, - List Pools, - DateTimeOffset CreatedAt, - DateTimeOffset? LastReconcileAt); - -public record PoolDetail( - int Servers, - int VolumesPerServer, - string StorageSize, - string StorageClass, - string? CpuRequest, - string? CpuLimit, - string? MemoryRequest, - string? MemoryLimit); - -public record MinioActionApiRequest( - string KubeConfig, - string ContextName); - -public record CreateMinioBucketApiRequest( - string KubeConfig, - string ContextName, - string BucketName); - -public record DeleteMinioBucketApiRequest( - string KubeConfig, - string ContextName, - string BucketName); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/AddDatabase/AddMongoDatabaseHandler.cs b/src/EntKube.Provisioning/Features/MongoClusters/AddDatabase/AddMongoDatabaseHandler.cs deleted file mode 100644 index 661d9d5..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/AddDatabase/AddMongoDatabaseHandler.cs +++ /dev/null @@ -1,73 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters.AddDatabase; - -/// -/// Handles requests to add a new database to an existing MongoDB cluster. -/// Applications can request databases and the platform creates them on the -/// appropriate MongoDBCommunity cluster with the specified owner user. -/// -/// The flow: -/// 1. Find the MongoCluster aggregate -/// 2. Ask the domain if a database can be added (must be Running, name unique) -/// 3. Call the K8s client to create the database on the actual cluster -/// 4. Persist the updated aggregate -/// -public class AddMongoDatabaseHandler -{ - private readonly IMongoClusterRepository repository; - private readonly IMongoClusterClient mongoClient; - - public AddMongoDatabaseHandler(IMongoClusterRepository repository, IMongoClusterClient mongoClient) - { - this.repository = repository; - this.mongoClient = mongoClient; - } - - public async Task> HandleAsync(AddMongoDatabaseRequest request, CancellationToken ct = default) - { - // Look up the target cluster. If it doesn't exist, fail early. - - MongoCluster? mongoCluster = await repository.GetByIdAsync(request.MongoClusterId, ct); - - if (mongoCluster is null) - { - return Result.Failure($"MongoDB cluster '{request.MongoClusterId}' not found."); - } - - // Ask the domain to validate and record the new database. - - Result domainResult = mongoCluster.AddDatabase(request.DatabaseName, request.OwnerRole); - - if (domainResult.IsFailure) - { - return domainResult; - } - - // Create the database on the actual K8s cluster. This runs a mongo shell - // command via a Job to initialize the database and create the owner user. - - await mongoClient.CreateDatabaseAsync( - request.KubeConfig, - request.ContextName, - mongoCluster.Name, - mongoCluster.Namespace, - request.DatabaseName, - request.OwnerRole, - ct); - - // Persist the updated aggregate. - - await repository.UpdateAsync(mongoCluster, ct); - - return domainResult; - } -} - -public record AddMongoDatabaseRequest( - Guid MongoClusterId, - string DatabaseName, - string OwnerRole, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/AdoptMongoCluster/AdoptMongoClustersHandler.cs b/src/EntKube.Provisioning/Features/MongoClusters/AdoptMongoCluster/AdoptMongoClustersHandler.cs deleted file mode 100644 index 4770452..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/AdoptMongoCluster/AdoptMongoClustersHandler.cs +++ /dev/null @@ -1,130 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster; - -/// -/// Handles adoption of existing MongoDB clusters. When a Kubernetes cluster already -/// has MongoDBCommunity clusters running, this handler discovers them and imports -/// them into the platform's domain so they can be managed and monitored. -/// -/// The flow: -/// 1. Call the K8s client to discover all MongoDBCommunity resources -/// 2. Check which clusters are already adopted (de-duplicate) -/// 3. For new clusters, query the live MongoDB for all databases -/// 4. For existing clusters, update databases with newly discovered ones -/// 5. Persist all changes -/// -public class AdoptMongoClustersHandler -{ - private readonly IMongoClusterRepository repository; - private readonly IMongoClusterClient mongoClient; - - public AdoptMongoClustersHandler(IMongoClusterRepository repository, IMongoClusterClient mongoClient) - { - this.repository = repository; - this.mongoClient = mongoClient; - } - - public async Task>> HandleAsync(AdoptMongoClustersRequest request, CancellationToken ct = default) - { - // Discover what's already running on the cluster. The K8s client reads - // all MongoDBCommunity CRs across all namespaces and extracts their config. - - List discovered = await mongoClient.DiscoverClustersAsync( - request.KubeConfig, request.ContextName, ct); - - // Load existing adopted clusters for this K8s cluster so we can - // de-duplicate. If a cluster with the same name and namespace already - // exists, we update it instead of creating a duplicate. - - IReadOnlyList existingClusters = - await repository.GetByClusterIdAsync(request.ClusterId, ct); - - List adoptedIds = new(); - - foreach (DiscoveredMongoCluster existing in discovered) - { - // Try to discover all databases from the live MongoDB instance. - // The CR spec only stores explicitly configured databases — live - // discovery catches databases created later via mongo shell. - - List liveDatabases = existing.Databases.ToList(); - - try - { - List liveResult = await mongoClient.ListLiveDatabasesAsync( - request.KubeConfig, request.ContextName, - existing.Name, existing.Namespace, ct); - - if (liveResult.Count > 0) - { - // Merge: start with live databases, add any CR-spec-only ones. - - foreach (string crDb in existing.Databases) - { - if (!liveResult.Contains(crDb, StringComparer.OrdinalIgnoreCase)) - { - liveResult.Add(crDb); - } - } - - liveDatabases = liveResult; - } - } - catch - { - // If live listing fails (e.g., auth secret not found), fall back - // to what the CR spec gave us. Acceptable for initial adoption. - } - - // Check if this cluster was already adopted. - - MongoCluster? alreadyAdopted = existingClusters.FirstOrDefault( - c => c.Name == existing.Name && c.Namespace == existing.Namespace); - - if (alreadyAdopted is not null) - { - // Update the existing aggregate with any newly discovered databases. - - foreach (string db in liveDatabases) - { - if (!alreadyAdopted.Databases.Contains(db, StringComparer.OrdinalIgnoreCase)) - { - alreadyAdopted.AddDatabase(db, db); - } - } - - await repository.UpdateAsync(alreadyAdopted, ct); - adoptedIds.Add(alreadyAdopted.Id); - continue; - } - - // New cluster — create a domain aggregate in Running state. - - MongoCluster mongoCluster = MongoCluster.Adopt( - environmentId: request.EnvironmentId, - clusterId: request.ClusterId, - name: existing.Name, - ns: existing.Namespace, - mongoVersion: existing.MongoVersion, - members: existing.Members, - storageSize: existing.StorageSize, - minioEndpoint: existing.MinioEndpoint ?? "minio.minio-system.svc:9000", - minioBucket: existing.MinioBucket ?? "mongo-backups", - minioCredentialsSecret: existing.MinioCredentialsSecret ?? "minio-mongo-credentials", - databases: liveDatabases); - - await repository.AddAsync(mongoCluster, ct); - adoptedIds.Add(mongoCluster.Id); - } - - return Result.Success(adoptedIds); - } -} - -public record AdoptMongoClustersRequest( - Guid EnvironmentId, - Guid ClusterId, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/ConfigureMongoCluster/ConfigureMongoClusterHandler.cs b/src/EntKube.Provisioning/Features/MongoClusters/ConfigureMongoCluster/ConfigureMongoClusterHandler.cs deleted file mode 100644 index 5097116..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/ConfigureMongoCluster/ConfigureMongoClusterHandler.cs +++ /dev/null @@ -1,116 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster; - -/// -/// Handles reconfiguration of an existing MongoDB cluster. Supports partial updates: -/// you can change just the member count, just the backup schedule, or both at once. -/// -/// Configurable properties: -/// - Member count (scaling up/down the replica set) -/// - Storage size (can only grow — Kubernetes PVC limitation) -/// - Backup schedule (cron expression for mongodump) -/// - Backup retention days -/// -/// The flow: -/// 1. Find the cluster -/// 2. Apply domain-level changes (validates invariants) -/// 3. Call K8s client to update the MongoDBCommunity resource and/or backup CronJob -/// 4. Persist the updated aggregate -/// -public class ConfigureMongoClusterHandler -{ - private readonly IMongoClusterRepository repository; - private readonly IMongoClusterClient mongoClient; - - public ConfigureMongoClusterHandler(IMongoClusterRepository repository, IMongoClusterClient mongoClient) - { - this.repository = repository; - this.mongoClient = mongoClient; - } - - public async Task> HandleAsync(ConfigureMongoClusterRequest request, CancellationToken ct = default) - { - // Find the target cluster. - - MongoCluster? mongoCluster = await repository.GetByIdAsync(request.MongoClusterId, ct); - - if (mongoCluster is null) - { - return Result.Failure($"MongoDB cluster '{request.MongoClusterId}' not found."); - } - - List changes = new(); - - // Apply member scaling if requested. - - if (request.Members.HasValue) - { - Result scaleResult = mongoCluster.ScaleMembers(request.Members.Value); - - if (scaleResult.IsFailure) - { - return scaleResult; - } - - changes.Add($"members={request.Members.Value}"); - } - - // Apply backup schedule changes if requested. - - if (request.BackupSchedule is not null || request.BackupRetentionDays.HasValue) - { - string schedule = request.BackupSchedule ?? mongoCluster.BackupConfig!.Schedule; - int retention = request.BackupRetentionDays ?? mongoCluster.BackupConfig!.RetentionDays; - - mongoCluster.ConfigureBackupSchedule(schedule, retention); - changes.Add($"backup={schedule}, retention={retention}d"); - - // Update the backup CronJob on K8s. - - await mongoClient.ConfigureScheduledBackupAsync( - request.KubeConfig, - request.ContextName, - mongoCluster.Name, - mongoCluster.Namespace, - schedule, - retention, - ct); - } - - // If member count or storage changed, update the MongoDBCommunity resource. - - if (request.Members.HasValue || request.StorageSize is not null) - { - MongoClusterSpec spec = new( - Name: mongoCluster.Name, - Namespace: mongoCluster.Namespace, - MongoVersion: mongoCluster.MongoVersion, - Members: mongoCluster.Members, - StorageSize: request.StorageSize ?? mongoCluster.StorageSize, - MinioEndpoint: mongoCluster.BackupConfig!.MinioEndpoint, - MinioBucket: mongoCluster.BackupConfig.Bucket, - MinioCredentialsSecret: mongoCluster.BackupConfig.CredentialsSecret, - BackupSchedule: mongoCluster.BackupConfig.Schedule, - BackupRetentionDays: mongoCluster.BackupConfig.RetentionDays); - - await mongoClient.UpdateClusterAsync(request.KubeConfig, request.ContextName, spec, ct); - } - - // Persist the updated aggregate. - - await repository.UpdateAsync(mongoCluster, ct); - - return Result.Success($"Cluster '{mongoCluster.Name}' reconfigured: {string.Join(", ", changes)}"); - } -} - -public record ConfigureMongoClusterRequest( - Guid MongoClusterId, - int? Members, - string? StorageSize, - string? BackupSchedule, - int? BackupRetentionDays, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/CreateMongoCluster/CreateMongoClusterHandler.cs b/src/EntKube.Provisioning/Features/MongoClusters/CreateMongoCluster/CreateMongoClusterHandler.cs deleted file mode 100644 index 7221ab4..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/CreateMongoCluster/CreateMongoClusterHandler.cs +++ /dev/null @@ -1,150 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster; - -/// -/// Handles requests to create a new MongoDB cluster. This is the "provision from scratch" -/// path — the platform admin specifies what they want and we create both the domain aggregate -/// AND the MongoDBCommunity resource on Kubernetes. -/// -/// MinIO is always required for MongoDB (mongodump backups). The handler resolves -/// MinIO details in two ways: -/// 1. **From adopted instance**: If no explicit details are provided, we look up the -/// adopted MinIO instance for this cluster and use its endpoint/credentials. -/// 2. **Explicit override**: If endpoint + secret are provided directly, we use those. -/// -/// The flow: -/// 1. Resolve MinIO configuration (from adoption or explicit values) -/// 2. Validate inputs (domain will enforce invariants) -/// 3. Create the MongoCluster aggregate (in Provisioning state) -/// 4. Call the K8s client to create the MongoDBCommunity resource -/// 5. Persist the aggregate -/// -public class CreateMongoClusterHandler -{ - private readonly IMongoClusterRepository repository; - private readonly IMongoClusterClient mongoClient; - private readonly IMinioInstanceRepository minioRepository; - - public CreateMongoClusterHandler( - IMongoClusterRepository repository, - IMongoClusterClient mongoClient, - IMinioInstanceRepository minioRepository) - { - this.repository = repository; - this.mongoClient = mongoClient; - this.minioRepository = minioRepository; - } - - public async Task> HandleAsync(CreateMongoClusterRequest request, CancellationToken ct = default) - { - // Resolve S3/MinIO configuration. If explicit endpoint and secret are provided, - // use those directly. Otherwise look up the adopted MinIO instance. If neither - // exists, the cluster is created without automated backups. - - string? minioEndpoint = null; - string? minioBucket = null; - string? minioCredentialsSecret = null; - - if (!string.IsNullOrWhiteSpace(request.MinioEndpoint) && - !string.IsNullOrWhiteSpace(request.MinioCredentialsSecret)) - { - // Explicit override — use the provided values directly. - // This supports both MinIO and external S3 providers (e.g. Cleura S3). - - minioEndpoint = request.MinioEndpoint; - minioBucket = request.MinioBucket ?? "mongo-backups"; - minioCredentialsSecret = request.MinioCredentialsSecret; - } - else - { - // Try to look up the adopted MinIO instance for this cluster. - - MinioInstance? minio = await minioRepository.GetByClusterIdAsync(request.ClusterId, ct); - - if (minio is not null) - { - minioEndpoint = minio.Endpoint; - minioBucket = request.MinioBucket ?? "mongo-backups"; - minioCredentialsSecret = minio.CredentialsSecret; - } - - // If no MinIO is found, we proceed without backups. - } - - // Let the domain validate inputs and create the aggregate. - - MongoCluster mongoCluster; - - try - { - mongoCluster = MongoCluster.Create( - environmentId: request.EnvironmentId, - clusterId: request.ClusterId, - name: request.Name, - ns: request.Namespace, - mongoVersion: request.MongoVersion, - members: request.Members, - storageSize: request.StorageSize, - minioEndpoint: minioEndpoint, - minioBucket: minioBucket, - minioCredentialsSecret: minioCredentialsSecret); - } - catch (ArgumentException ex) - { - return Result.Failure(ex.Message); - } - - // Create the MongoDBCommunity resource on Kubernetes. This tells the operator - // to start deploying MongoDB pods, configuring the replica set, setting up - // authentication, and optionally creating a backup CronJob. - - MongoClusterSpec spec = new( - Name: mongoCluster.Name, - Namespace: mongoCluster.Namespace, - MongoVersion: mongoCluster.MongoVersion, - Members: mongoCluster.Members, - StorageSize: mongoCluster.StorageSize, - MinioEndpoint: mongoCluster.BackupConfig?.MinioEndpoint, - MinioBucket: mongoCluster.BackupConfig?.Bucket, - MinioCredentialsSecret: mongoCluster.BackupConfig?.CredentialsSecret, - BackupSchedule: mongoCluster.BackupConfig?.Schedule, - BackupRetentionDays: mongoCluster.BackupConfig?.RetentionDays, - S3AccessKey: request.S3AccessKey, - S3SecretKey: request.S3SecretKey, - S3Region: request.S3Region); - - await mongoClient.CreateClusterAsync(request.KubeConfig, request.ContextName, spec, ct); - - // Persist the aggregate. The reconciler will poll its status later. - - await repository.AddAsync(mongoCluster, ct); - - return Result.Success(mongoCluster.Id); - } -} - -/// -/// Request to create a new MongoDBCommunity cluster. -/// The cluster belongs to an environment and runs on the specified hosting cluster. -/// MinioEndpoint and MinioCredentialsSecret are optional — if omitted, the handler -/// resolves them from the adopted MinIO instance for the target cluster. -/// MinioBucket defaults to "mongo-backups" if not specified. -/// -public record CreateMongoClusterRequest( - Guid EnvironmentId, - Guid ClusterId, - string Name, - string Namespace, - string MongoVersion, - int Members, - string StorageSize, - string KubeConfig, - string ContextName, - string? MinioEndpoint, - string? MinioBucket, - string? MinioCredentialsSecret, - string? S3AccessKey = null, - string? S3SecretKey = null, - string? S3Region = null); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/IMongoClusterClient.cs b/src/EntKube.Provisioning/Features/MongoClusters/IMongoClusterClient.cs deleted file mode 100644 index 352e3ad..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/IMongoClusterClient.cs +++ /dev/null @@ -1,275 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters; - -/// -/// Defines the contract for interacting with MongoDB Community Operator resources on -/// a Kubernetes cluster. This interface abstracts the K8s API calls so feature handlers -/// can orchestrate domain logic without coupling to the Kubernetes client directly. -/// -/// The implementation will use KubernetesClient to create/patch/delete MongoDBCommunity -/// custom resources and manage backup CronJobs for mongodump to MinIO. -/// -/// MongoDB Community Operator CRD: -/// - apiVersion: mongodbcommunity.mongodb.com/v1 -/// - kind: MongoDBCommunity -/// - Manages replica sets with SCRAM auth, TLS, and persistent storage -/// -public interface IMongoClusterClient -{ - /// - /// Discovers existing MongoDBCommunity resources on the K8s cluster. - /// Returns basic metadata about each cluster found (name, namespace, version, members, databases). - /// - Task> DiscoverClustersAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Creates a new MongoDBCommunity resource on K8s with the specified configuration. - /// This includes storage, authentication, replica set members, and backup CronJob. - /// - Task CreateClusterAsync(string kubeConfig, string contextName, MongoClusterSpec spec, CancellationToken ct = default); - - /// - /// Updates an existing MongoDBCommunity resource (scale members, change resources, etc.). - /// - Task UpdateClusterAsync(string kubeConfig, string contextName, MongoClusterSpec spec, CancellationToken ct = default); - - /// - /// Initiates a controlled MongoDB version upgrade on the MongoDBCommunity resource. - /// The Community Operator handles rolling updates: secondaries first, then primary stepdown. - /// Respects featureCompatibilityVersion to ensure safe transitions. - /// - Task UpgradeClusterAsync(string kubeConfig, string contextName, string name, string ns, string targetVersion, CancellationToken ct = default); - - /// - /// Creates a database and owner user on the specified MongoDB cluster. - /// This runs a mongo shell command via a K8s Job against the primary to - /// create the database and a user with readWrite role. - /// - Task CreateDatabaseAsync(string kubeConfig, string contextName, string clusterName, string ns, string databaseName, string ownerRole, CancellationToken ct = default); - - /// - /// Reads the current status of a MongoDBCommunity resource (phase, members ready, etc.). - /// - Task GetClusterStatusAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Creates or updates a CronJob for scheduled mongodump backups to MinIO. - /// The CronJob runs mongodump against the secondary, compresses the output, - /// and uploads it to the configured MinIO bucket. - /// - Task ConfigureScheduledBackupAsync(string kubeConfig, string contextName, string clusterName, string ns, string schedule, int retentionDays, CancellationToken ct = default); - - /// - /// Checks what MongoDB versions are available for upgrade (based on available images). - /// Returns versions supported by the MongoDB Community Operator. - /// - Task> GetAvailableVersionsAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Reads live health details from the MongoDBCommunity resource and its pods, - /// including per-member status, replication lag, oplog window, and replica set state. - /// - Task GetClusterHealthAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Lists all backup files stored in MinIO for this cluster. Reads the backup - /// bucket path and lists objects with their metadata. - /// - Task> ListBackupsAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Triggers an immediate on-demand mongodump backup via a K8s Job. - /// - Task TriggerBackupAsync(string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default); - - /// - /// Drops a database from the cluster by running a mongo shell command - /// via a K8s Job against the primary. - /// - Task DeleteDatabaseAsync(string kubeConfig, string contextName, string clusterName, string ns, string databaseName, CancellationToken ct = default); - - /// - /// Deletes the MongoDBCommunity CR from Kubernetes. The operator handles - /// cleanup of the StatefulSet, pods, services, and other owned resources. - /// - Task DeleteClusterAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Requests a rolling restart of all members by annotating the - /// MongoDBCommunity resource. The operator restarts pods one by one. - /// - Task RestartClusterAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Initiates a controlled stepdown — forces the current primary to step - /// down so another member can be elected. Used for maintenance or testing. - /// - Task StepDownPrimaryAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Lists all user databases on the running MongoDB cluster by querying - /// the primary via mongo shell. Returns database names excluding system - /// databases (admin, local, config). - /// - Task> ListLiveDatabasesAsync(string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default); - - /// - /// Restores a MongoDB cluster from a mongodump backup stored in MinIO. - /// Creates a new MongoDBCommunity resource and runs a mongorestore Job - /// after the replica set is initialized. - /// - Task RestoreBackupAsync(string kubeConfig, string contextName, RestoreFromMongoBackupSpec spec, CancellationToken ct = default); - - /// - /// Returns raw diagnostic data about backup CronJobs and stored backups - /// for debugging. Checks CronJob status, recent Job completions, and - /// MinIO bucket contents. - /// - Task DiagnoseBackupsAsync(string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default); -} - -/// -/// Metadata about a MongoDBCommunity cluster discovered during adoption. -/// -public record DiscoveredMongoCluster( - string Name, - string Namespace, - string MongoVersion, - int Members, - string StorageSize, - List Databases, - string? MinioEndpoint, - string? MinioBucket, - string? MinioCredentialsSecret); - -/// -/// Specification for creating or updating a MongoDBCommunity resource. -/// Backup fields are optional — when all three are provided, a backup CronJob is created. -/// When omitted, the cluster runs without automated backups. -/// -public record MongoClusterSpec( - string Name, - string Namespace, - string MongoVersion, - int Members, - string StorageSize, - string? MinioEndpoint = null, - string? MinioBucket = null, - string? MinioCredentialsSecret = null, - string? BackupSchedule = null, - int? BackupRetentionDays = null, - List? Databases = null, - string? S3AccessKey = null, - string? S3SecretKey = null, - string? S3Region = null) -{ - public bool HasBackupConfig => - !string.IsNullOrWhiteSpace(MinioEndpoint) && - !string.IsNullOrWhiteSpace(MinioBucket) && - !string.IsNullOrWhiteSpace(MinioCredentialsSecret); -} - -/// -/// Current status of a MongoDBCommunity cluster as reported by the operator. -/// -public record MongoCrdStatus( - string Phase, - int ReadyMembers, - int TotalMembers, - string? CurrentPrimary, - DateTimeOffset? LastBackupAt); - -/// -/// Comprehensive health snapshot of a MongoDB cluster, combining the CRD status -/// with pod-level details and replication information. -/// -public record MongoClusterHealth( - string Phase, - int ReadyMembers, - int TotalMembers, - string? CurrentPrimary, - long OplogWindowHours, - string? LastBackup, - List MemberDetails, - MongoReplicationInfo? Replication); - -/// -/// Per-member (pod) information for a MongoDB replica set. -/// -public record MongoMemberInfo( - string Name, - string Role, - string State, - bool Ready, - long? ReplicationLagSeconds, - string? PodPhase); - -/// -/// Replication summary across the MongoDB replica set. -/// -public record MongoReplicationInfo( - bool ReplicationActive, - int SyncMembers, - long MaxLagSeconds); - -/// -/// Represents a single backup (mongodump archive) for a MongoDB cluster. -/// -public record MongoBackupInfo( - string Name, - string Status, - DateTimeOffset? StartedAt, - DateTimeOffset? CompletedAt, - string? BackupSize, - string? DestinationPath, - string? Error); - -/// -/// Specification for restoring a MongoDB cluster from a mongodump backup. -/// Creates a new MongoDBCommunity resource and runs mongorestore after init. -/// -public record RestoreFromMongoBackupSpec( - string SourceClusterName, - string RestoredClusterName, - string Namespace, - string MongoVersion, - int Members, - string StorageSize, - string MinioEndpoint, - string MinioBucket, - string MinioCredentialsSecret, - string? BackupName, - DateTimeOffset? TargetTime); - -/// -/// Raw diagnostics for debugging MongoDB backup problems. Contains info about -/// CronJobs, recent Job runs, and MinIO bucket contents. -/// -public record MongoBackupDiagnostics( - string ClusterName, - string Namespace, - string? LastBackup, - int TotalBackupsInBucket, - List CronJobs, - List RecentJobs); - -/// -/// Summary of a backup CronJob for diagnostics. -/// -public record MongoBackupCronJobSummary( - string Name, - string? Namespace, - string? Schedule, - string? LastScheduleTime, - bool Suspended); - -/// -/// Summary of a recent backup Job for diagnostics. -/// -public record MongoBackupJobSummary( - string Name, - string? Namespace, - string? Status, - DateTimeOffset? StartedAt, - DateTimeOffset? CompletedAt); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/MongoClusterEndpoints.cs b/src/EntKube.Provisioning/Features/MongoClusters/MongoClusterEndpoints.cs deleted file mode 100644 index f5325e6..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/MongoClusterEndpoints.cs +++ /dev/null @@ -1,556 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MongoClusters.AddDatabase; -using EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.RestoreBackup; -using EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.MongoClusters; - -/// -/// Maps all MongoDB cluster management endpoints under /api/mongo-clusters. -/// These expose the full lifecycle: create, adopt, add databases, upgrade, -/// configure, backup/restore, health, and operational actions. -/// -public static class MongoClusterEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/mongo-clusters"); - - // POST /api/mongo-clusters — Create a new MongoDB cluster. - - group.MapPost("/", async ( - [FromBody] CreateMongoClusterRequest request, - [FromServices] CreateMongoClusterHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created($"/api/mongo-clusters/{result.Value}", ApiResponse.Ok(result.Value!)); - }); - - // POST /api/mongo-clusters/adopt — Discover and import existing MongoDB clusters. - - group.MapPost("/adopt", async ( - [FromBody] AdoptMongoClustersRequest request, - [FromServices] AdoptMongoClustersHandler handler, - CancellationToken ct) => - { - Result> result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse>.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse>.Ok(result.Value!)); - }); - - // POST /api/mongo-clusters/{id}/databases — Add a database to an existing cluster. - - group.MapPost("/{id:guid}/databases", async ( - Guid id, - [FromBody] AddMongoDatabaseApiRequest body, - [FromServices] AddMongoDatabaseHandler handler, - CancellationToken ct) => - { - AddMongoDatabaseRequest request = new(id, body.DatabaseName, body.OwnerRole, body.KubeConfig, body.ContextName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // POST /api/mongo-clusters/{id}/upgrade — Initiate a controlled version upgrade. - - group.MapPost("/{id:guid}/upgrade", async ( - Guid id, - [FromBody] MongoUpgradeApiRequest body, - [FromServices] UpgradeMongoClusterHandler handler, - CancellationToken ct) => - { - UpgradeMongoClusterRequest request = new(id, body.TargetVersion, body.KubeConfig, body.ContextName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // PUT /api/mongo-clusters/{id}/configure — Reconfigure cluster settings. - - group.MapPut("/{id:guid}/configure", async ( - Guid id, - [FromBody] MongoConfigureApiRequest body, - [FromServices] ConfigureMongoClusterHandler handler, - CancellationToken ct) => - { - ConfigureMongoClusterRequest request = new( - id, body.Members, body.StorageSize, - body.BackupSchedule, body.BackupRetentionDays, - body.KubeConfig, body.ContextName); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // GET /api/mongo-clusters — List all MongoDB clusters. - - group.MapGet("/", async ( - [FromQuery] Guid? clusterId, - [FromServices] IMongoClusterRepository repository, - CancellationToken ct) => - { - IReadOnlyList clusters = clusterId.HasValue - ? await repository.GetByClusterIdAsync(clusterId.Value, ct) - : await repository.GetAllAsync(ct); - - List summaries = clusters.Select(c => new MongoClusterSummary( - c.Id, c.ClusterId, c.Name, c.Namespace, c.MongoVersion, - c.Members, c.StorageSize, c.Status.ToString(), - c.Databases.ToList(), - c.BackupConfig?.Schedule, - c.BackupConfig?.RetentionDays, - c.TargetVersion)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - - // GET /api/mongo-clusters/{id} — Get a single cluster by ID. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IMongoClusterRepository repository, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster '{id}' not found.")); - } - - MongoClusterSummary summary = new( - cluster.Id, cluster.ClusterId, cluster.Name, cluster.Namespace, - cluster.MongoVersion, cluster.Members, cluster.StorageSize, - cluster.Status.ToString(), cluster.Databases.ToList(), - cluster.BackupConfig?.Schedule, cluster.BackupConfig?.RetentionDays, - cluster.TargetVersion); - - return Results.Ok(ApiResponse.Ok(summary)); - }); - - // POST /api/mongo-clusters/{id}/health — Live health from K8s. - // Reads the MongoDBCommunity CR and pods to get member status, - // replication lag, oplog window, and overall cluster health. - - group.MapPost("/{id:guid}/health", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - MongoClusterHealth? health = await mongoClient.GetClusterHealthAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - if (health is null) - { - return Results.Ok(ApiResponse.Fail("Could not read cluster health.")); - } - - return Results.Ok(ApiResponse.Ok(health)); - }); - - // POST /api/mongo-clusters/{id}/backups — List all backups for a cluster. - - group.MapPost("/{id:guid}/backups", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - try - { - List backups = await mongoClient.ListBackupsAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse>.Ok(backups)); - } - catch (Exception ex) - { - return Results.Ok(ApiResponse>.Fail( - $"Failed to list backups: {ex.Message}")); - } - }); - - // POST /api/mongo-clusters/{id}/backups/diagnose — Raw diagnostics for backup debugging. - - group.MapPost("/{id:guid}/backups/diagnose", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - try - { - MongoBackupDiagnostics diagnostics = await mongoClient.DiagnoseBackupsAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok(diagnostics)); - } - catch (Exception ex) - { - return Results.Ok(ApiResponse.Fail( - $"Diagnostics failed: {ex.Message}")); - } - }); - - // POST /api/mongo-clusters/{id}/backup — Trigger an on-demand backup. - - group.MapPost("/{id:guid}/backup", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await mongoClient.TriggerBackupAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Backup triggered.")); - }); - - // POST /api/mongo-clusters/{id}/restart — Rolling restart. - - group.MapPost("/{id:guid}/restart", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await mongoClient.RestartClusterAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Rolling restart initiated.")); - }); - - // POST /api/mongo-clusters/{id}/stepdown — Primary stepdown. - - group.MapPost("/{id:guid}/stepdown", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await mongoClient.StepDownPrimaryAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Primary stepdown initiated.")); - }); - - // DELETE /api/mongo-clusters/{id}/databases/{dbName} — Drop a database. - - group.MapDelete("/{id:guid}/databases/{dbName}", async ( - Guid id, string dbName, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - // Remove from domain model. - - cluster.RemoveDatabase(dbName); - await repository.UpdateAsync(cluster, ct); - - // Drop on K8s. - - await mongoClient.DeleteDatabaseAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, dbName, ct); - - return Results.Ok(ApiResponse.Ok($"Database '{dbName}' dropped.")); - }); - - // DELETE /api/mongo-clusters/{id} — Delete an entire MongoDB cluster. - // Removes the MongoDBCommunity CR from K8s and the record from the local store. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - // Delete the CR on K8s — the operator cleans up pods, services, PVCs. - - await mongoClient.DeleteClusterAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - // Remove from the local store. - - await repository.DeleteAsync(id, ct); - - return Results.Ok(ApiResponse.Ok($"MongoDB cluster '{cluster.Name}' deleted.")); - }); - - // POST /api/mongo-clusters/{id}/restore/preview — Dry-run preview of a restore. - - group.MapPost("/{id:guid}/restore/preview", async ( - Guid id, - [FromBody] MongoRestorePreviewApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? source = await repository.GetByIdAsync(id, ct); - - if (source is null) - { - return Results.NotFound(ApiResponse.Fail("Source cluster not found.")); - } - - string minioEndpoint = source.BackupConfig?.MinioEndpoint ?? "minio.minio-system.svc:9000"; - string minioBucket = source.BackupConfig?.Bucket ?? "mongo-backups"; - string destinationPath = $"s3://{minioBucket}/{source.Name}/"; - - string recoveryMethod = body.TargetTime.HasValue - ? "Point-in-Time Recovery (oplog replay)" - : body.BackupName is not null - ? $"Restore from backup '{body.BackupName}'" - : "Restore from latest backup"; - - List warnings = new(); - - MongoClusterHealth? health = await mongoClient.GetClusterHealthAsync( - body.KubeConfig, body.ContextName, source.Name, source.Namespace, ct); - - if (health?.LastBackup is null) - { - warnings.Add("No successful backup recorded. Restore may fail if no backup exists in object storage."); - } - - if (source.BackupConfig is null) - { - warnings.Add("Source cluster has no backup configuration."); - } - - string restoredName = string.IsNullOrWhiteSpace(body.RestoredClusterName) - ? $"{source.Name}-restored" - : body.RestoredClusterName; - - MongoRestorePreview preview = new( - SourceClusterName: source.Name, - SourceNamespace: source.Namespace, - MongoVersion: source.MongoVersion, - Members: source.Members, - StorageSize: source.StorageSize, - RestoredClusterName: restoredName, - BackupName: body.BackupName, - TargetTime: body.TargetTime, - RecoveryMethod: recoveryMethod, - MinioEndpoint: minioEndpoint, - MinioBucket: minioBucket, - DestinationPath: destinationPath, - LastBackup: health?.LastBackup, - Warnings: warnings); - - return Results.Ok(ApiResponse.Ok(preview)); - }); - - // POST /api/mongo-clusters/{id}/restore — Restore from a backup. - - group.MapPost("/{id:guid}/restore", async ( - Guid id, - [FromBody] MongoRestoreApiRequest body, - [FromServices] RestoreMongoBackupHandler handler, - CancellationToken ct) => - { - RestoreMongoBackupRequest request = new( - MongoClusterId: id, - BackupName: body.BackupName, - RestoredClusterName: body.RestoredClusterName, - KubeConfig: body.KubeConfig, - ContextName: body.ContextName, - TargetTime: body.TargetTime); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created( - $"/api/mongo-clusters/{result.Value}", - ApiResponse.Ok(result.Value!)); - }); - - // POST /api/mongo-clusters/{id}/databases/live — List databases from running instance. - - group.MapPost("/{id:guid}/databases/live", async ( - Guid id, - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterRepository repository, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - MongoCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - List databases = await mongoClient.ListLiveDatabasesAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse>.Ok(databases)); - }); - - // POST /api/mongo-clusters/available-versions — List supported MongoDB versions. - - group.MapPost("/available-versions", async ( - [FromBody] MongoActionApiRequest body, - [FromServices] IMongoClusterClient mongoClient, - CancellationToken ct) => - { - List versions = await mongoClient.GetAvailableVersionsAsync( - body.KubeConfig, body.ContextName, ct); - - return Results.Ok(ApiResponse>.Ok(versions)); - }); - } -} - -// API request/response DTOs. - -public record AddMongoDatabaseApiRequest(string DatabaseName, string OwnerRole, string KubeConfig, string ContextName); - -public record MongoUpgradeApiRequest(string TargetVersion, string KubeConfig, string ContextName); - -public record MongoConfigureApiRequest( - int? Members, string? StorageSize, - string? BackupSchedule, int? BackupRetentionDays, - string KubeConfig, string ContextName); - -public record MongoActionApiRequest(string KubeConfig, string ContextName); - -public record MongoRestoreApiRequest( - string? BackupName, - string RestoredClusterName, - string KubeConfig, - string ContextName, - DateTimeOffset? TargetTime); - -public record MongoRestorePreviewApiRequest( - string? BackupName, - string? RestoredClusterName, - string KubeConfig, - string ContextName, - DateTimeOffset? TargetTime); - -public record MongoClusterSummary( - Guid Id, Guid ClusterId, string Name, string Namespace, - string MongoVersion, int Members, string StorageSize, - string Status, List Databases, - string? BackupSchedule, int? BackupRetentionDays, - string? TargetVersion); - -public record MongoRestorePreview( - string SourceClusterName, - string SourceNamespace, - string MongoVersion, - int Members, - string StorageSize, - string RestoredClusterName, - string? BackupName, - DateTimeOffset? TargetTime, - string RecoveryMethod, - string MinioEndpoint, - string MinioBucket, - string DestinationPath, - string? LastBackup, - List Warnings); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/RestoreBackup/RestoreMongoBackupHandler.cs b/src/EntKube.Provisioning/Features/MongoClusters/RestoreBackup/RestoreMongoBackupHandler.cs deleted file mode 100644 index 36a0daf..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/RestoreBackup/RestoreMongoBackupHandler.cs +++ /dev/null @@ -1,87 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters.RestoreBackup; - -/// -/// Handles restoring a MongoDB cluster from a mongodump backup. When a user wants -/// to recover data, this handler creates a brand new MongoDBCommunity cluster and -/// runs a mongorestore Job after the replica set is initialized. -/// -/// The flow: -/// 1. Find the source cluster (we need its backup config and metadata) -/// 2. Build a restore specification with the backup source details -/// 3. Call the MongoDB client to create the restored cluster on K8s -/// 4. Register the new cluster in the domain as a Provisioning aggregate -/// -public class RestoreMongoBackupHandler -{ - private readonly IMongoClusterRepository repository; - private readonly IMongoClusterClient mongoClient; - - public RestoreMongoBackupHandler(IMongoClusterRepository repository, IMongoClusterClient mongoClient) - { - this.repository = repository; - this.mongoClient = mongoClient; - } - - public async Task> HandleAsync(RestoreMongoBackupRequest request, CancellationToken ct = default) - { - // First, find the source cluster so we know where the backup data lives. - - MongoCluster? source = await repository.GetByIdAsync(request.MongoClusterId, ct); - - if (source is null) - { - return Result.Failure($"Source cluster '{request.MongoClusterId}' not found."); - } - - // Build the restore specification from the source cluster's config. - - RestoreFromMongoBackupSpec spec = new( - SourceClusterName: source.Name, - RestoredClusterName: request.RestoredClusterName, - Namespace: source.Namespace, - MongoVersion: source.MongoVersion, - Members: source.Members, - StorageSize: source.StorageSize, - MinioEndpoint: source.BackupConfig?.MinioEndpoint ?? "minio.minio-system.svc:9000", - MinioBucket: source.BackupConfig?.Bucket ?? "mongo-backups", - MinioCredentialsSecret: source.BackupConfig?.CredentialsSecret ?? "minio-mongo-credentials", - BackupName: request.BackupName, - TargetTime: request.TargetTime); - - // Create the restored cluster on K8s. This creates a new MongoDBCommunity - // resource and a mongorestore Job that pulls the backup from MinIO. - - await mongoClient.RestoreBackupAsync(request.KubeConfig, request.ContextName, spec, ct); - - // Register the restored cluster in the domain. It starts in Provisioning - // state because the operator needs time to initialize the replica set - // and the mongorestore Job needs time to complete. - - MongoCluster restored = MongoCluster.Create( - environmentId: source.EnvironmentId, - clusterId: source.ClusterId, - name: request.RestoredClusterName, - ns: source.Namespace, - mongoVersion: source.MongoVersion, - members: source.Members, - storageSize: source.StorageSize, - minioEndpoint: source.BackupConfig?.MinioEndpoint ?? "minio.minio-system.svc:9000", - minioBucket: source.BackupConfig?.Bucket ?? "mongo-backups", - minioCredentialsSecret: source.BackupConfig?.CredentialsSecret ?? "minio-mongo-credentials"); - - await repository.AddAsync(restored, ct); - - return Result.Success(restored.Id); - } -} - -public record RestoreMongoBackupRequest( - Guid MongoClusterId, - string? BackupName, - string RestoredClusterName, - string KubeConfig, - string ContextName, - DateTimeOffset? TargetTime); diff --git a/src/EntKube.Provisioning/Features/MongoClusters/UpgradeMongoCluster/UpgradeMongoClusterHandler.cs b/src/EntKube.Provisioning/Features/MongoClusters/UpgradeMongoCluster/UpgradeMongoClusterHandler.cs deleted file mode 100644 index 5aa4967..0000000 --- a/src/EntKube.Provisioning/Features/MongoClusters/UpgradeMongoCluster/UpgradeMongoClusterHandler.cs +++ /dev/null @@ -1,90 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster; - -/// -/// Handles controlled MongoDB version upgrades. Before initiating an upgrade, -/// this handler verifies the target version is actually available. -/// Then it transitions the domain to Upgrading state and tells the operator -/// to perform the rolling upgrade. -/// -/// MongoDB version upgrades must respect featureCompatibilityVersion (FCV): -/// - Minor version upgrades (e.g., 7.0.11 → 7.0.12) are straightforward -/// - Major version upgrades (e.g., 6.0 → 7.0) require FCV to be set first -/// - The Community Operator handles the rolling update sequence -/// -/// The flow: -/// 1. Find the cluster and validate it's upgradeable -/// 2. Check available versions from the K8s cluster -/// 3. Ask the domain to transition to Upgrading state -/// 4. Call the K8s client to update the image version -/// 5. Persist the updated aggregate -/// -public class UpgradeMongoClusterHandler -{ - private readonly IMongoClusterRepository repository; - private readonly IMongoClusterClient mongoClient; - - public UpgradeMongoClusterHandler(IMongoClusterRepository repository, IMongoClusterClient mongoClient) - { - this.repository = repository; - this.mongoClient = mongoClient; - } - - public async Task> HandleAsync(UpgradeMongoClusterRequest request, CancellationToken ct = default) - { - // Find the cluster. Can't upgrade what doesn't exist. - - MongoCluster? mongoCluster = await repository.GetByIdAsync(request.MongoClusterId, ct); - - if (mongoCluster is null) - { - return Result.Failure($"MongoDB cluster '{request.MongoClusterId}' not found."); - } - - // Verify the target version is available. - - List availableVersions = await mongoClient.GetAvailableVersionsAsync( - request.KubeConfig, request.ContextName, ct); - - if (!availableVersions.Contains(request.TargetVersion)) - { - return Result.Failure( - $"Version '{request.TargetVersion}' is not available. " + - $"Available versions: {string.Join(", ", availableVersions)}"); - } - - // Ask the domain to transition. - - Result domainResult = mongoCluster.RequestUpgrade(request.TargetVersion); - - if (domainResult.IsFailure) - { - return domainResult; - } - - // Tell the operator to perform the upgrade. It handles the rolling - // update: secondaries first, then primary stepdown and upgrade. - - await mongoClient.UpgradeClusterAsync( - request.KubeConfig, - request.ContextName, - mongoCluster.Name, - mongoCluster.Namespace, - request.TargetVersion, - ct); - - // Persist the updated aggregate. - - await repository.UpdateAsync(mongoCluster, ct); - - return domainResult; - } -} - -public record UpgradeMongoClusterRequest( - Guid MongoClusterId, - string TargetVersion, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/Monitoring/GrafanaInstanceEndpoints.cs b/src/EntKube.Provisioning/Features/Monitoring/GrafanaInstanceEndpoints.cs deleted file mode 100644 index 0a0cdc9..0000000 --- a/src/EntKube.Provisioning/Features/Monitoring/GrafanaInstanceEndpoints.cs +++ /dev/null @@ -1,424 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.Monitoring; - -/// -/// Maps all Grafana instance endpoints. A GrafanaInstance represents the centralized -/// visualization platform for an environment — one Grafana per environment, deployed -/// to a specific cluster, connecting to all Prometheus instances in that environment. -/// -public static class GrafanaInstanceEndpoints -{ - public static void Map(WebApplication app) - { - RouteGroupBuilder group = app.MapGroup("/api/grafana"); - - // ─── Instance Lifecycle ───────────────────────────────────── - - // POST /api/grafana — Create a new Grafana instance for an environment. - - group.MapPost("/", async ( - [FromBody] CreateGrafanaInstanceRequest body, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - // Only one Grafana instance per environment. - - GrafanaInstance? existing = await repository.GetByEnvironmentIdAsync(body.EnvironmentId, ct); - - if (existing is not null) - { - return Results.Conflict(ApiResponse.Fail( - $"Environment {body.EnvironmentId} already has a Grafana instance.")); - } - - GrafanaInstance instance = GrafanaInstance.Create( - body.EnvironmentId, body.ClusterId, body.Name, body.Namespace); - - await repository.SaveAsync(instance, ct); - - return Results.Created($"/api/grafana/{instance.Id}", - ApiResponse.Ok(MapToDto(instance))); - }); - - // GET /api/grafana — List all Grafana instances. - - group.MapGet("/", async ( - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - IReadOnlyList instances = await repository.GetAllAsync(ct); - List dtos = instances.Select(MapToDto).ToList(); - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - - // GET /api/grafana/environment/{environmentId} — Get the Grafana instance for an environment. - - group.MapGet("/environment/{environmentId:guid}", async ( - Guid environmentId, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByEnvironmentIdAsync(environmentId, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail( - $"No Grafana instance found for environment {environmentId}.")); - } - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // GET /api/grafana/{id} — Get a Grafana instance by ID. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // DELETE /api/grafana/{id} — Delete a Grafana instance. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - await repository.DeleteAsync(id, ct); - return Results.Ok(ApiResponse.Ok(new { deleted = true })); - }); - - // ─── Persistence Configuration ────────────────────────────── - - // PUT /api/grafana/{id}/persistence — Configure persistence settings. - - group.MapPut("/{id:guid}/persistence", async ( - Guid id, - [FromBody] ConfigurePersistenceRequest body, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - instance.ConfigurePersistence(body.Enabled, body.Size); - await repository.SaveAsync(instance, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // ─── OIDC ─────────────────────────────────────────────────── - - // PUT /api/grafana/{id}/oidc — Configure OIDC authentication. - - group.MapPut("/{id:guid}/oidc", async ( - Guid id, - [FromBody] ConfigureOidcRequest body, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - OidcConfiguration oidc = new( - Provider: body.Provider, - ClientId: body.ClientId, - ClientSecretRef: body.ClientSecretRef, - AuthUrl: body.AuthUrl, - TokenUrl: body.TokenUrl, - ApiUrl: body.ApiUrl, - Scopes: body.Scopes, - RoleAttributePath: body.RoleAttributePath, - AutoLogin: body.AutoLogin); - - instance.ConfigureOidc(oidc); - await repository.SaveAsync(instance, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // DELETE /api/grafana/{id}/oidc — Remove OIDC from Grafana. - - group.MapDelete("/{id:guid}/oidc", async ( - Guid id, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - instance.RemoveOidc(); - await repository.SaveAsync(instance, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // ─── Ingress ──────────────────────────────────────────────── - - // PUT /api/grafana/{id}/ingress — Configure Grafana ingress. - - group.MapPut("/{id:guid}/ingress", async ( - Guid id, - [FromBody] ConfigureGrafanaIngressRequest body, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - GrafanaIngressConfiguration ingress = new( - Enabled: body.Enabled, - Provider: body.Provider, - Hostname: body.Hostname, - TlsEnabled: body.TlsEnabled, - TlsCertificateMode: body.TlsCertificateMode, - TlsSecretName: body.TlsSecretName, - ClusterIssuer: body.ClusterIssuer); - - instance.ConfigureIngress(ingress); - await repository.SaveAsync(instance, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // DELETE /api/grafana/{id}/ingress — Disable Grafana ingress. - - group.MapDelete("/{id:guid}/ingress", async ( - Guid id, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - instance.DisableIngress(); - await repository.SaveAsync(instance, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // ─── Dashboards ───────────────────────────────────────────── - - // POST /api/grafana/{id}/dashboards — Add a dashboard. - - group.MapPost("/{id:guid}/dashboards", async ( - Guid id, - [FromBody] AddDashboardRequest body, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - GrafanaDashboard dashboard = new( - Name: body.Name, - DisplayName: body.DisplayName, - Category: body.Category, - JsonContent: body.JsonContent); - - instance.AddDashboard(dashboard); - await repository.SaveAsync(instance, ct); - - return Results.Created($"/api/grafana/{id}/dashboards/{dashboard.Name}", - ApiResponse.Ok(MapToDto(instance))); - }); - - // DELETE /api/grafana/{id}/dashboards/{name} — Remove a dashboard. - - group.MapDelete("/{id:guid}/dashboards/{name}", async ( - Guid id, - string name, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - instance.RemoveDashboard(name); - await repository.SaveAsync(instance, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(instance))); - }); - - // GET /api/grafana/{id}/dashboards — List all dashboards. - - group.MapGet("/{id:guid}/dashboards", async ( - Guid id, - [FromServices] IGrafanaInstanceRepository repository, - CancellationToken ct) => - { - GrafanaInstance? instance = await repository.GetByIdAsync(id, ct); - - if (instance is null) - { - return Results.NotFound(ApiResponse.Fail($"Grafana instance {id} not found.")); - } - - List dtos = instance.Dashboards.Select(d => new DashboardDto( - d.Name, d.DisplayName, d.Category.ToString())).ToList(); - - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - } - - // ─── DTO Mapping ──────────────────────────────────────────────── - - private static GrafanaInstanceDto MapToDto(GrafanaInstance instance) - { - return new GrafanaInstanceDto( - Id: instance.Id, - EnvironmentId: instance.EnvironmentId, - ClusterId: instance.ClusterId, - Name: instance.Name, - Namespace: instance.Namespace, - Status: instance.Status.ToString(), - CreatedAt: instance.CreatedAt, - Persistence: instance.Persistence, - PersistenceSize: instance.PersistenceSize, - Oidc: instance.Oidc is not null - ? new OidcDto( - instance.Oidc.Provider, - instance.Oidc.ClientId, - instance.Oidc.ClientSecretRef, - instance.Oidc.AuthUrl, - instance.Oidc.TokenUrl, - instance.Oidc.ApiUrl, - instance.Oidc.Scopes, - instance.Oidc.RoleAttributePath, - instance.Oidc.AutoLogin) - : null, - Ingress: instance.Ingress is not null - ? new GrafanaIngressDto( - instance.Ingress.Enabled, - instance.Ingress.Provider.ToString(), - instance.Ingress.Hostname, - instance.Ingress.TlsEnabled, - instance.Ingress.TlsCertificateMode.ToString(), - instance.Ingress.TlsSecretName, - instance.Ingress.ClusterIssuer) - : null, - Dashboards: instance.Dashboards.Select(d => new DashboardDto( - d.Name, d.DisplayName, d.Category.ToString())).ToList()); - } -} - -// ─── Request DTOs ─────────────────────────────────────────────────── - -public record CreateGrafanaInstanceRequest( - Guid EnvironmentId, - Guid ClusterId, - string Name, - string Namespace); - -public record ConfigurePersistenceRequest(bool Enabled, string? Size); - -public record ConfigureOidcRequest( - string Provider, - string ClientId, - string ClientSecretRef, - string AuthUrl, - string TokenUrl, - string ApiUrl, - string Scopes, - string? RoleAttributePath, - bool AutoLogin); - -public record ConfigureGrafanaIngressRequest( - bool Enabled, - IngressProvider Provider, - string? Hostname, - bool TlsEnabled, - TlsCertificateMode TlsCertificateMode = TlsCertificateMode.Manual, - string? TlsSecretName = null, - string? ClusterIssuer = null); - -public record AddDashboardRequest( - string Name, - string DisplayName, - DashboardCategory Category, - string JsonContent); - -// ─── Response DTOs ────────────────────────────────────────────────── - -public record GrafanaInstanceDto( - Guid Id, - Guid EnvironmentId, - Guid ClusterId, - string Name, - string Namespace, - string Status, - DateTimeOffset CreatedAt, - bool Persistence, - string PersistenceSize, - OidcDto? Oidc, - GrafanaIngressDto? Ingress, - List Dashboards); - -public record OidcDto( - string Provider, - string ClientId, - string ClientSecretRef, - string AuthUrl, - string TokenUrl, - string ApiUrl, - string Scopes, - string? RoleAttributePath, - bool AutoLogin); - -public record GrafanaIngressDto( - bool Enabled, - string Provider, - string? Hostname, - bool TlsEnabled, - string TlsCertificateMode, - string? TlsSecretName, - string? ClusterIssuer); - -public record DashboardDto(string Name, string DisplayName, string Category); diff --git a/src/EntKube.Provisioning/Features/Monitoring/PrometheusStackEndpoints.cs b/src/EntKube.Provisioning/Features/Monitoring/PrometheusStackEndpoints.cs deleted file mode 100644 index 84e0559..0000000 --- a/src/EntKube.Provisioning/Features/Monitoring/PrometheusStackEndpoints.cs +++ /dev/null @@ -1,313 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.Monitoring; - -/// -/// Maps all Prometheus stack endpoints. A PrometheusStack represents the per-cluster -/// metrics collection and alerting infrastructure — Prometheus for scraping/storing -/// metrics and Alertmanager for routing alerts. -/// -public static class PrometheusStackEndpoints -{ - public static void Map(WebApplication app) - { - RouteGroupBuilder group = app.MapGroup("/api/prometheus"); - - // ─── Stack Lifecycle ──────────────────────────────────────── - - // POST /api/prometheus — Create a new Prometheus stack on a cluster. - - group.MapPost("/", async ( - [FromBody] CreatePrometheusStackRequest body, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - // Only one Prometheus stack per cluster. - - PrometheusStack? existing = await repository.GetByClusterIdAsync(body.ClusterId, ct); - - if (existing is not null) - { - return Results.Conflict(ApiResponse.Fail( - $"Cluster {body.ClusterId} already has a Prometheus stack.")); - } - - PrometheusStack stack = PrometheusStack.Create( - body.ClusterId, body.EnvironmentId, body.Name, body.Namespace); - - await repository.SaveAsync(stack, ct); - - return Results.Created($"/api/prometheus/{stack.Id}", - ApiResponse.Ok(MapToDto(stack))); - }); - - // GET /api/prometheus — List all Prometheus stacks. - - group.MapGet("/", async ( - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - IReadOnlyList stacks = await repository.GetAllAsync(ct); - List dtos = stacks.Select(MapToDto).ToList(); - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - - // GET /api/prometheus/cluster/{clusterId} — Get the Prometheus stack for a cluster. - - group.MapGet("/cluster/{clusterId:guid}", async ( - Guid clusterId, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByClusterIdAsync(clusterId, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail( - $"No Prometheus stack found for cluster {clusterId}.")); - } - - return Results.Ok(ApiResponse.Ok(MapToDto(stack))); - }); - - // GET /api/prometheus/environment/{environmentId} — Get all Prometheus stacks in an environment. - - group.MapGet("/environment/{environmentId:guid}", async ( - Guid environmentId, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - IReadOnlyList stacks = await repository.GetByEnvironmentIdAsync(environmentId, ct); - List dtos = stacks.Select(MapToDto).ToList(); - return Results.Ok(ApiResponse>.Ok(dtos)); - }); - - // GET /api/prometheus/{id} — Get a Prometheus stack by ID. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByIdAsync(id, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail($"Prometheus stack {id} not found.")); - } - - return Results.Ok(ApiResponse.Ok(MapToDto(stack))); - }); - - // DELETE /api/prometheus/{id} — Delete a Prometheus stack. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByIdAsync(id, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail($"Prometheus stack {id} not found.")); - } - - await repository.DeleteAsync(id, ct); - return Results.Ok(ApiResponse.Ok(new { deleted = true })); - }); - - // ─── Prometheus Configuration ─────────────────────────────── - - // PUT /api/prometheus/{id}/config — Configure Prometheus settings. - - group.MapPut("/{id:guid}/config", async ( - Guid id, - [FromBody] ConfigurePrometheusRequest body, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByIdAsync(id, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail($"Prometheus stack {id} not found.")); - } - - stack.ConfigurePrometheus( - retention: body.Retention, - replicas: body.Replicas, - storageSize: body.StorageSize, - retentionSize: body.RetentionSize); - - await repository.SaveAsync(stack, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(stack))); - }); - - // ─── Alertmanager Configuration ───────────────────────────── - - // PUT /api/prometheus/{id}/alertmanager — Configure Alertmanager settings. - - group.MapPut("/{id:guid}/alertmanager", async ( - Guid id, - [FromBody] ConfigureAlertmanagerRequest body, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByIdAsync(id, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail($"Prometheus stack {id} not found.")); - } - - stack.ConfigureAlertmanager(enabled: body.Enabled, replicas: body.Replicas); - await repository.SaveAsync(stack, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(stack))); - }); - - // ─── Ingress / Gateway API ────────────────────────────────── - - // PUT /api/prometheus/{id}/ingress — Configure ingress for Prometheus and Alertmanager. - - group.MapPut("/{id:guid}/ingress", async ( - Guid id, - [FromBody] ConfigureIngressRequest body, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByIdAsync(id, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail($"Prometheus stack {id} not found.")); - } - - IngressConfiguration ingress = new( - Enabled: body.Enabled, - Provider: body.Provider, - PrometheusHostname: body.PrometheusHostname, - AlertmanagerHostname: body.AlertmanagerHostname, - TlsEnabled: body.TlsEnabled, - TlsCertificateMode: body.TlsCertificateMode, - TlsSecretName: body.TlsSecretName, - ClusterIssuer: body.ClusterIssuer); - - stack.ConfigureIngress(ingress); - await repository.SaveAsync(stack, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(stack))); - }); - - // DELETE /api/prometheus/{id}/ingress — Disable ingress routing. - - group.MapDelete("/{id:guid}/ingress", async ( - Guid id, - [FromServices] IPrometheusStackRepository repository, - CancellationToken ct) => - { - PrometheusStack? stack = await repository.GetByIdAsync(id, ct); - - if (stack is null) - { - return Results.NotFound(ApiResponse.Fail($"Prometheus stack {id} not found.")); - } - - stack.DisableIngress(); - await repository.SaveAsync(stack, ct); - - return Results.Ok(ApiResponse.Ok(MapToDto(stack))); - }); - } - - // ─── DTO Mapping ──────────────────────────────────────────────── - - private static PrometheusStackDto MapToDto(PrometheusStack stack) - { - return new PrometheusStackDto( - Id: stack.Id, - ClusterId: stack.ClusterId, - EnvironmentId: stack.EnvironmentId, - Name: stack.Name, - Namespace: stack.Namespace, - Status: stack.Status.ToString(), - CreatedAt: stack.CreatedAt, - Prometheus: new PrometheusDto( - stack.Prometheus.Retention, - stack.Prometheus.RetentionSize, - stack.Prometheus.Replicas, - stack.Prometheus.StorageSize), - Alertmanager: new AlertmanagerDto( - stack.Alertmanager.Enabled, - stack.Alertmanager.Replicas), - Ingress: stack.Ingress is not null - ? new IngressDto( - stack.Ingress.Enabled, - stack.Ingress.Provider.ToString(), - stack.Ingress.PrometheusHostname, - stack.Ingress.AlertmanagerHostname, - stack.Ingress.TlsEnabled, - stack.Ingress.TlsCertificateMode.ToString(), - stack.Ingress.TlsSecretName, - stack.Ingress.ClusterIssuer) - : null); - } -} - -// ─── Request DTOs ─────────────────────────────────────────────────── - -public record CreatePrometheusStackRequest( - Guid ClusterId, - Guid EnvironmentId, - string Name, - string Namespace); - -public record ConfigurePrometheusRequest( - string? Retention, - int? Replicas, - string? StorageSize, - string? RetentionSize); - -public record ConfigureAlertmanagerRequest(bool? Enabled, int? Replicas); - -public record ConfigureIngressRequest( - bool Enabled, - IngressProvider Provider, - string? PrometheusHostname, - string? AlertmanagerHostname, - bool TlsEnabled, - TlsCertificateMode TlsCertificateMode = TlsCertificateMode.Manual, - string? TlsSecretName = null, - string? ClusterIssuer = null); - -// ─── Response DTOs ────────────────────────────────────────────────── - -public record PrometheusStackDto( - Guid Id, - Guid ClusterId, - Guid EnvironmentId, - string Name, - string Namespace, - string Status, - DateTimeOffset CreatedAt, - PrometheusDto Prometheus, - AlertmanagerDto Alertmanager, - IngressDto? Ingress); - -public record PrometheusDto(string Retention, string? RetentionSize, int Replicas, string StorageSize); - -public record AlertmanagerDto(bool Enabled, int Replicas); - -public record IngressDto( - bool Enabled, - string Provider, - string? PrometheusHostname, - string? AlertmanagerHostname, - bool TlsEnabled, - string TlsCertificateMode, - string? TlsSecretName, - string? ClusterIssuer); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/AddDatabase/AddDatabaseHandler.cs b/src/EntKube.Provisioning/Features/PostgresClusters/AddDatabase/AddDatabaseHandler.cs deleted file mode 100644 index d143be2..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/AddDatabase/AddDatabaseHandler.cs +++ /dev/null @@ -1,74 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters.AddDatabase; - -/// -/// Handles requests to add a new database to an existing PostgreSQL cluster. -/// This is the "database provisioning" feature — applications can request databases -/// and the platform creates them on the appropriate CNPG cluster. -/// -/// The flow: -/// 1. Find the PostgresCluster aggregate -/// 2. Ask the domain if a database can be added (must be Running, name unique) -/// 3. Call the K8s client to create the database on the actual cluster -/// 4. Persist the updated aggregate -/// -public class AddDatabaseHandler -{ - private readonly IPostgresClusterRepository repository; - private readonly ICnpgClusterClient cnpgClient; - - public AddDatabaseHandler(IPostgresClusterRepository repository, ICnpgClusterClient cnpgClient) - { - this.repository = repository; - this.cnpgClient = cnpgClient; - } - - public async Task> HandleAsync(AddDatabaseRequest request, CancellationToken ct = default) - { - // Look up the target cluster. If it doesn't exist, fail early. - - PostgresCluster? pgCluster = await repository.GetByIdAsync(request.PostgresClusterId, ct); - - if (pgCluster is null) - { - return Result.Failure($"PostgreSQL cluster '{request.PostgresClusterId}' not found."); - } - - // Ask the domain to validate and record the new database. - // The domain enforces rules: cluster must be Running, name must be unique. - - Result domainResult = pgCluster.AddDatabase(request.DatabaseName, request.OwnerRole); - - if (domainResult.IsFailure) - { - return domainResult; - } - - // Create the database on the actual K8s cluster via CNPG. - // This either updates the Cluster spec or runs a SQL Job. - - await cnpgClient.CreateDatabaseAsync( - request.KubeConfig, - request.ContextName, - pgCluster.Name, - pgCluster.Namespace, - request.DatabaseName, - request.OwnerRole, - ct); - - // Persist the updated aggregate. - - await repository.UpdateAsync(pgCluster, ct); - - return domainResult; - } -} - -public record AddDatabaseRequest( - Guid PostgresClusterId, - string DatabaseName, - string OwnerRole, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/AdoptPostgresCluster/AdoptPostgresClustersHandler.cs b/src/EntKube.Provisioning/Features/PostgresClusters/AdoptPostgresCluster/AdoptPostgresClustersHandler.cs deleted file mode 100644 index e5cd538..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/AdoptPostgresCluster/AdoptPostgresClustersHandler.cs +++ /dev/null @@ -1,131 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster; - -/// -/// Handles adoption of existing PostgreSQL clusters. When a Kubernetes cluster already -/// has CNPG clusters running, this handler discovers them and imports them into the -/// platform's domain so they can be managed, monitored, and have databases added. -/// -/// The flow: -/// 1. Call the K8s client to discover all CNPG Cluster resources -/// 2. Check which clusters are already adopted (de-duplicate) -/// 3. For new clusters, query the live PostgreSQL for all databases -/// 4. For existing clusters, update databases with newly discovered ones -/// 5. Persist all changes -/// -public class AdoptPostgresClustersHandler -{ - private readonly IPostgresClusterRepository repository; - private readonly ICnpgClusterClient cnpgClient; - - public AdoptPostgresClustersHandler(IPostgresClusterRepository repository, ICnpgClusterClient cnpgClient) - { - this.repository = repository; - this.cnpgClient = cnpgClient; - } - - public async Task>> HandleAsync(AdoptPostgresClustersRequest request, CancellationToken ct = default) - { - // Discover what's already running on the cluster. The K8s client reads - // all CNPG Cluster CRs across all namespaces and extracts their config. - - List discovered = await cnpgClient.DiscoverClustersAsync( - request.KubeConfig, request.ContextName, ct); - - // Load existing adopted clusters for this K8s cluster so we can - // de-duplicate. If a cluster with the same name and namespace already - // exists, we update it instead of creating a duplicate. - - IReadOnlyList existingClusters = - await repository.GetByClusterIdAsync(request.ClusterId, ct); - - List adoptedIds = new(); - - foreach (DiscoveredPostgresCluster existing in discovered) - { - // Try to discover all databases from the live PostgreSQL instance. - // The CR spec only stores the bootstrap database — live discovery - // catches databases created later via SQL. - - List liveDatabases = existing.Databases.ToList(); - - try - { - List liveResult = await cnpgClient.ListLiveDatabasesAsync( - request.KubeConfig, request.ContextName, - existing.Name, existing.Namespace, ct); - - if (liveResult.Count > 0) - { - // Merge: start with live databases, add any CR-spec-only ones. - - foreach (string crDb in existing.Databases) - { - if (!liveResult.Contains(crDb, StringComparer.OrdinalIgnoreCase)) - { - liveResult.Add(crDb); - } - } - - liveDatabases = liveResult; - } - } - catch - { - // If live listing fails (e.g., superuser secret not found), fall back - // to what the CR spec gave us. This is acceptable for initial adoption. - } - - // Check if this cluster was already adopted. - - PostgresCluster? alreadyAdopted = existingClusters.FirstOrDefault( - c => c.Name == existing.Name && c.Namespace == existing.Namespace); - - if (alreadyAdopted is not null) - { - // Update the existing aggregate with any newly discovered databases - // and refresh instance/version data from the live CR. - - foreach (string db in liveDatabases) - { - if (!alreadyAdopted.Databases.Contains(db, StringComparer.OrdinalIgnoreCase)) - { - alreadyAdopted.AddDatabase(db, db); - } - } - - await repository.UpdateAsync(alreadyAdopted, ct); - adoptedIds.Add(alreadyAdopted.Id); - continue; - } - - // New cluster — create a domain aggregate in Running state. - - PostgresCluster pgCluster = PostgresCluster.Adopt( - environmentId: request.EnvironmentId, - clusterId: request.ClusterId, - name: existing.Name, - ns: existing.Namespace, - postgresVersion: existing.PostgresVersion, - instances: existing.Instances, - storageSize: existing.StorageSize, - minioEndpoint: existing.MinioEndpoint ?? "minio.minio-system.svc:9000", - minioBucket: existing.MinioBucket ?? "cnpg-backups", - minioCredentialsSecret: existing.MinioCredentialsSecret ?? "minio-cnpg-credentials", - databases: liveDatabases); - - await repository.AddAsync(pgCluster, ct); - adoptedIds.Add(pgCluster.Id); - } - - return Result.Success(adoptedIds); - } -} - -public record AdoptPostgresClustersRequest( - Guid EnvironmentId, - Guid ClusterId, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/ConfigurePostgresCluster/ConfigurePostgresClusterHandler.cs b/src/EntKube.Provisioning/Features/PostgresClusters/ConfigurePostgresCluster/ConfigurePostgresClusterHandler.cs deleted file mode 100644 index 9764cf9..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/ConfigurePostgresCluster/ConfigurePostgresClusterHandler.cs +++ /dev/null @@ -1,118 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster; - -/// -/// Handles reconfiguration of an existing PostgreSQL cluster. Supports partial updates: -/// you can change just the instance count, just the backup schedule, or both at once. -/// -/// Configurable properties: -/// - Instance count (scaling up/down) -/// - Storage size (can only grow, never shrink — Kubernetes PVC limitation) -/// - Backup schedule (cron expression) -/// - Backup retention days -/// -/// The flow: -/// 1. Find the cluster -/// 2. Apply domain-level changes (validates invariants) -/// 3. Call K8s client to update the CNPG Cluster resource and/or ScheduledBackup -/// 4. Persist the updated aggregate -/// -public class ConfigurePostgresClusterHandler -{ - private readonly IPostgresClusterRepository repository; - private readonly ICnpgClusterClient cnpgClient; - - public ConfigurePostgresClusterHandler(IPostgresClusterRepository repository, ICnpgClusterClient cnpgClient) - { - this.repository = repository; - this.cnpgClient = cnpgClient; - } - - public async Task> HandleAsync(ConfigurePostgresClusterRequest request, CancellationToken ct = default) - { - // Find the target cluster. - - PostgresCluster? pgCluster = await repository.GetByIdAsync(request.PostgresClusterId, ct); - - if (pgCluster is null) - { - return Result.Failure($"PostgreSQL cluster '{request.PostgresClusterId}' not found."); - } - - List changes = new(); - - // Apply instance scaling if requested. - - if (request.Instances.HasValue) - { - Result scaleResult = pgCluster.ScaleInstances(request.Instances.Value); - - if (scaleResult.IsFailure) - { - return scaleResult; - } - - changes.Add($"instances={request.Instances.Value}"); - } - - // Apply backup schedule changes if requested. - - if (request.BackupSchedule is not null || request.BackupRetentionDays.HasValue) - { - string schedule = request.BackupSchedule ?? pgCluster.BackupConfig!.Schedule; - int retention = request.BackupRetentionDays ?? pgCluster.BackupConfig!.RetentionDays; - - pgCluster.ConfigureBackupSchedule(schedule, retention); - changes.Add($"backup={schedule}, retention={retention}d"); - - // Update the ScheduledBackup resource on K8s. - - await cnpgClient.ConfigureScheduledBackupAsync( - request.KubeConfig, - request.ContextName, - pgCluster.Name, - pgCluster.Namespace, - schedule, - retention, - ct); - } - - // If instance count or storage changed, update the CNPG Cluster resource. - - if (request.Instances.HasValue || request.StorageSize is not null) - { - CnpgClusterSpec spec = new( - Name: pgCluster.Name, - Namespace: pgCluster.Namespace, - PostgresVersion: pgCluster.PostgresVersion, - Instances: pgCluster.Instances, - StorageSize: request.StorageSize ?? pgCluster.StorageSize, - MinioEndpoint: pgCluster.BackupConfig!.MinioEndpoint, - MinioBucket: pgCluster.BackupConfig.Bucket, - MinioCredentialsSecret: pgCluster.BackupConfig.CredentialsSecret, - WalArchivingEnabled: pgCluster.BackupConfig.WalArchivingEnabled, - BackupSchedule: pgCluster.BackupConfig.Schedule, - BackupRetentionDays: pgCluster.BackupConfig.RetentionDays, - S3Region: pgCluster.BackupConfig.S3Region); - - await cnpgClient.UpdateClusterAsync(request.KubeConfig, request.ContextName, spec, ct); - } - - // Persist the updated aggregate. - - await repository.UpdateAsync(pgCluster, ct); - - return Result.Success($"Cluster '{pgCluster.Name}' reconfigured: {string.Join(", ", changes)}"); - } -} - -public record ConfigurePostgresClusterRequest( - Guid PostgresClusterId, - int? Instances, - string? StorageSize, - string? BackupSchedule, - int? BackupRetentionDays, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/CreatePostgresCluster/CreatePostgresClusterHandler.cs b/src/EntKube.Provisioning/Features/PostgresClusters/CreatePostgresCluster/CreatePostgresClusterHandler.cs deleted file mode 100644 index 12ac28c..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/CreatePostgresCluster/CreatePostgresClusterHandler.cs +++ /dev/null @@ -1,157 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster; - -/// -/// Handles requests to create a new PostgreSQL cluster. This is the "provision from scratch" -/// path — the platform admin specifies what they want and we create both the domain aggregate -/// AND the CNPG Cluster resource on Kubernetes. -/// -/// MinIO is always required for CNPG (WAL archiving and base backups). The handler resolves -/// MinIO details in two ways: -/// 1. **From adopted instance**: If no explicit details are provided, we look up the -/// adopted MinIO instance for this cluster and use its endpoint/credentials. -/// 2. **Explicit override**: If endpoint + secret are provided directly, we use those -/// (useful for custom or external MinIO deployments). -/// -/// The flow: -/// 1. Resolve MinIO configuration (from adoption or explicit values) -/// 2. Validate inputs (domain will enforce invariants) -/// 3. Create the PostgresCluster aggregate (in Provisioning state) -/// 4. Call the K8s client to create the CNPG Cluster resource -/// 5. Persist the aggregate -/// -public class CreatePostgresClusterHandler -{ - private readonly IPostgresClusterRepository repository; - private readonly ICnpgClusterClient cnpgClient; - private readonly IMinioInstanceRepository minioRepository; - - public CreatePostgresClusterHandler( - IPostgresClusterRepository repository, - ICnpgClusterClient cnpgClient, - IMinioInstanceRepository minioRepository) - { - this.repository = repository; - this.cnpgClient = cnpgClient; - this.minioRepository = minioRepository; - } - - public async Task> HandleAsync(CreatePostgresClusterRequest request, CancellationToken ct = default) - { - // Resolve S3/MinIO configuration. If explicit endpoint and secret are provided, - // use those directly. Otherwise look up the adopted MinIO instance. If neither - // exists, the cluster is created without automated backups. - - string? minioEndpoint = null; - string? minioBucket = null; - string? minioCredentialsSecret = null; - - string? s3Region = null; - - if (!string.IsNullOrWhiteSpace(request.MinioEndpoint) && - !string.IsNullOrWhiteSpace(request.MinioCredentialsSecret)) - { - // Explicit override — use the provided values directly. - // This supports both MinIO and external S3 providers (e.g. Cleura S3). - - minioEndpoint = request.MinioEndpoint; - minioBucket = request.MinioBucket ?? "cnpg-backups"; - minioCredentialsSecret = request.MinioCredentialsSecret; - s3Region = request.S3Region; - } - else - { - // Try to look up the adopted MinIO instance for this cluster. - - MinioInstance? minio = await minioRepository.GetByClusterIdAsync(request.ClusterId, ct); - - if (minio is not null) - { - minioEndpoint = minio.Endpoint; - minioBucket = request.MinioBucket ?? "cnpg-backups"; - minioCredentialsSecret = minio.CredentialsSecret; - } - - // If no MinIO is found, we proceed without backups. - } - - // Let the domain validate inputs and create the aggregate. - // If inputs are invalid, the domain throws — we catch and return a failure. - - PostgresCluster pgCluster; - - try - { - pgCluster = PostgresCluster.Create( - environmentId: request.EnvironmentId, - clusterId: request.ClusterId, - name: request.Name, - ns: request.Namespace, - postgresVersion: request.PostgresVersion, - instances: request.Instances, - storageSize: request.StorageSize, - minioEndpoint: minioEndpoint, - minioBucket: minioBucket, - minioCredentialsSecret: minioCredentialsSecret, - s3Region: s3Region); - } - catch (ArgumentException ex) - { - return Result.Failure(ex.Message); - } - - // Create the CNPG Cluster resource on Kubernetes. This tells the operator - // to start deploying PostgreSQL pods, configuring storage, and optionally - // setting up WAL archiving and backups to S3. - - CnpgClusterSpec spec = new( - Name: pgCluster.Name, - Namespace: pgCluster.Namespace, - PostgresVersion: pgCluster.PostgresVersion, - Instances: pgCluster.Instances, - StorageSize: pgCluster.StorageSize, - MinioEndpoint: pgCluster.BackupConfig?.MinioEndpoint, - MinioBucket: pgCluster.BackupConfig?.Bucket, - MinioCredentialsSecret: pgCluster.BackupConfig?.CredentialsSecret, - WalArchivingEnabled: pgCluster.BackupConfig?.WalArchivingEnabled ?? false, - BackupSchedule: pgCluster.BackupConfig?.Schedule, - BackupRetentionDays: pgCluster.BackupConfig?.RetentionDays, - S3Region: pgCluster.BackupConfig?.S3Region, - S3AccessKey: request.S3AccessKey, - S3SecretKey: request.S3SecretKey); - - await cnpgClient.CreateClusterAsync(request.KubeConfig, request.ContextName, spec, ct); - - // Persist the aggregate. The reconciler will poll its status later. - - await repository.AddAsync(pgCluster, ct); - - return Result.Success(pgCluster.Id); - } -} - -/// -/// Request to create a new CNPG PostgreSQL cluster. -/// The cluster belongs to an environment and runs on the specified hosting cluster. -/// MinioEndpoint and MinioCredentialsSecret are optional — if omitted, the handler -/// resolves them from the adopted MinIO instance for the target cluster. -/// MinioBucket defaults to "cnpg-backups" if not specified. -/// -public record CreatePostgresClusterRequest( - Guid EnvironmentId, - Guid ClusterId, - string Name, - string Namespace, - string PostgresVersion, - int Instances, - string StorageSize, - string KubeConfig, - string ContextName, - string? MinioEndpoint, - string? MinioBucket, - string? MinioCredentialsSecret, - string? S3Region = null, - string? S3AccessKey = null, - string? S3SecretKey = null); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/ICnpgClusterClient.cs b/src/EntKube.Provisioning/Features/PostgresClusters/ICnpgClusterClient.cs deleted file mode 100644 index 08e7eb3..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/ICnpgClusterClient.cs +++ /dev/null @@ -1,312 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters; - -/// -/// Defines the contract for interacting with CloudNativePG resources on a Kubernetes -/// cluster. This interface abstracts the K8s API calls so feature handlers can -/// orchestrate domain logic without coupling to the Kubernetes client directly. -/// -/// The implementation will use KubernetesClient to create/patch/delete CNPG custom -/// resources (Cluster, ScheduledBackup, etc.) and read their status. -/// -public interface ICnpgClusterClient -{ - /// - /// Discovers existing CNPG Cluster resources on the K8s cluster. - /// Returns basic metadata about each cluster found (name, namespace, version, instances, databases). - /// - Task> DiscoverClustersAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Creates a new CNPG Cluster resource on K8s with the specified configuration. - /// This includes storage, WAL archiving to MinIO, and instance count. - /// - Task CreateClusterAsync(string kubeConfig, string contextName, CnpgClusterSpec spec, CancellationToken ct = default); - - /// - /// Updates an existing CNPG Cluster resource (scale instances, change resources, etc.). - /// - Task UpdateClusterAsync(string kubeConfig, string contextName, CnpgClusterSpec spec, CancellationToken ct = default); - - /// - /// Initiates a controlled PostgreSQL version upgrade on the CNPG Cluster. - /// CNPG handles the rolling update process (switchover, replica updates). - /// - Task UpgradeClusterAsync(string kubeConfig, string contextName, string name, string ns, string targetVersion, CancellationToken ct = default); - - /// - /// Creates a database and owner role on the specified CNPG Cluster. - /// This updates the Cluster spec's bootstrap.initdb or runs SQL via a Job. - /// - Task CreateDatabaseAsync(string kubeConfig, string contextName, string clusterName, string ns, string databaseName, string ownerRole, CancellationToken ct = default); - - /// - /// Reads the current status of a CNPG Cluster resource (phase, instances ready, etc.). - /// - Task GetClusterStatusAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Creates or updates a ScheduledBackup resource for the cluster. - /// - Task ConfigureScheduledBackupAsync(string kubeConfig, string contextName, string clusterName, string ns, string schedule, int retentionDays, CancellationToken ct = default); - - /// - /// Checks what PostgreSQL versions are available for upgrade (based on available images). - /// - Task> GetAvailableVersionsAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Reads live health details from the CNPG Cluster resource and its pods, - /// including per-instance status, replication lag, WAL archiving, timeline, - /// and resource usage summaries. - /// - Task GetClusterHealthAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Lists all backups (Backup CRs) for a CNPG Cluster, ordered by start time descending. - /// Includes both scheduled and on-demand backups. - /// - Task> ListBackupsAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Triggers an immediate on-demand backup for the cluster via a Backup CR. - /// - Task TriggerBackupAsync(string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default); - - /// - /// Drops a database and optionally its owner role from the cluster by - /// running a psql Job against the primary. - /// - Task DeleteDatabaseAsync(string kubeConfig, string contextName, string clusterName, string ns, string databaseName, CancellationToken ct = default); - - /// - /// Deletes the CNPG Cluster CR from Kubernetes. The operator handles - /// cleanup of pods, services, PVCs, and other owned resources. - /// - Task DeleteClusterAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Requests a rolling restart of all instances in the cluster by annotating - /// the CNPG Cluster resource. The operator restarts pods one by one. - /// - Task RestartClusterAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Initiates a controlled switchover — promotes a replica to primary and - /// demotes the current primary to replica. Zero-downtime operation. - /// - Task FailoverAsync(string kubeConfig, string contextName, string name, string ns, string? targetInstance, CancellationToken ct = default); - - /// - /// Lists all user databases on the running PostgreSQL cluster by querying - /// pg_database via a psql command against the primary. Returns database - /// names excluding system databases (template0, template1, postgres). - /// - Task> ListLiveDatabasesAsync(string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default); - - /// - /// Creates a new CNPG Cluster from a backup or point-in-time recovery source. - /// This uses the CNPG recovery bootstrap method, which creates a new cluster - /// that restores from the specified backup's object store data. - /// - Task RestoreBackupAsync(string kubeConfig, string contextName, RestoreFromBackupSpec spec, CancellationToken ct = default); - - /// - /// Returns raw diagnostic data about all Backup CRs visible in the cluster's - /// namespace (and cluster-wide), without any filtering. Used to debug why - /// backups might not be showing up in the normal listing. - /// - Task DiagnoseBackupsAsync(string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default); -} - -/// -/// Metadata about a CNPG Cluster discovered during adoption. -/// -public record DiscoveredPostgresCluster( - string Name, - string Namespace, - string PostgresVersion, - int Instances, - string StorageSize, - List Databases, - string? MinioEndpoint, - string? MinioBucket, - string? MinioCredentialsSecret, - bool WalArchivingEnabled); - -/// -/// Specification for creating or updating a CNPG Cluster resource. -/// Backup fields are optional — when endpoint, bucket, and secret are provided, -/// Barman WAL archiving and scheduled backups are configured. When omitted, the -/// cluster runs without automated backups. -/// -public record CnpgClusterSpec( - string Name, - string Namespace, - string PostgresVersion, - int Instances, - string StorageSize, - string? MinioEndpoint = null, - string? MinioBucket = null, - string? MinioCredentialsSecret = null, - bool WalArchivingEnabled = false, - string? BackupSchedule = null, - int? BackupRetentionDays = null, - List? Databases = null, - string? S3Region = null, - string? S3AccessKey = null, - string? S3SecretKey = null) -{ - public bool HasBackupConfig => - !string.IsNullOrWhiteSpace(MinioEndpoint) && - !string.IsNullOrWhiteSpace(MinioBucket) && - !string.IsNullOrWhiteSpace(MinioCredentialsSecret); -} - -/// -/// Current status of a CNPG Cluster as reported by the operator. -/// -public record CnpgClusterStatus( - string Phase, - int ReadyInstances, - int TotalInstances, - string? CurrentPrimary, - string? LatestWalArchived, - DateTimeOffset? LastBackupAt); - -/// -/// Comprehensive health snapshot of a CNPG Cluster, combining the Cluster status -/// with pod-level details and WAL archiving information. -/// -public record CnpgClusterHealth( - string Phase, - int ReadyInstances, - int TotalInstances, - string? CurrentPrimary, - string? CurrentPrimaryTimestamp, - string? TargetPrimary, - int CurrentTimeline, - string? LatestWalArchived, - string? FirstRecoverabilityPoint, - string? LastSuccessfulBackup, - string? StorageUsed, - string? WalStorageUsed, - List Instances, - CnpgReplicationInfo? Replication); - -/// -/// Per-instance (pod) information for a CNPG Cluster member. -/// -public record CnpgInstanceInfo( - string Name, - string Role, - string Status, - bool Ready, - string? CurrentLsn, - string? ReceivedLsn, - string? ReplayedLsn, - long? ReplicationLagBytes, - string? TimelineId, - string? PodPhase); - -/// -/// Streaming replication summary across the cluster. -/// -public record CnpgReplicationInfo( - bool StreamingActive, - int SyncReplicas, - long MaxLagBytes); - -/// -/// Represents a single backup (Backup CR) for a CNPG Cluster. -/// -public record CnpgBackupInfo( - string Name, - string Phase, - string? Method, - DateTimeOffset? StartedAt, - DateTimeOffset? CompletedAt, - string? BackupSize, - string? WalSize, - string? DestinationPath, - string? Error, - int? TimelineId, - string? BeginLsn, - string? EndLsn); - -/// -/// Specification for restoring a CNPG Cluster from a backup. CNPG creates -/// a new cluster using the recovery bootstrap method, reading base backup -/// data and WAL segments from the object store. -/// -public record RestoreFromBackupSpec( - string SourceClusterName, - string RestoredClusterName, - string Namespace, - string PostgresVersion, - int Instances, - string StorageSize, - string MinioEndpoint, - string MinioBucket, - string MinioCredentialsSecret, - string? BackupName, - DateTimeOffset? TargetTime, - string? S3Region = null); - -/// -/// Raw diagnostics for debugging backup detection problems. Contains unfiltered -/// information about all Backup CRs found and the cluster's backup status fields. -/// -public record BackupDiagnostics( - string ClusterName, - string Namespace, - string? FirstRecoverabilityPoint, - string? LastSuccessfulBackup, - int TotalBackupCRsInNamespace, - int TotalBackupCRsClusterWide, - List AllBackupCRs, - List ScheduledBackups); - -/// -/// Preview of what a restore operation would produce. This is a dry-run calculation -/// that shows the user exactly what will happen before they commit. -/// -public record RestorePreview( - string SourceClusterName, - string SourceNamespace, - string PostgresVersion, - int Instances, - string StorageSize, - string RestoredClusterName, - string? BackupName, - DateTimeOffset? TargetTime, - string RecoveryMethod, - string MinioEndpoint, - string MinioBucket, - string DestinationPath, - string? FirstRecoverabilityPoint, - string? LastSuccessfulBackup, - List Warnings); - -/// -/// A raw summary of a single Backup CR, without any filtering applied. -/// -public record BackupCRSummary( - string Name, - string? Namespace, - string? Phase, - string? ClusterRef, - string? Method, - DateTimeOffset? StartedAt, - DateTimeOffset? CompletedAt); - -/// -/// Summary of a ScheduledBackup CR for diagnostics. -/// -public record ScheduledBackupSummary( - string Name, - string? Namespace, - string? Schedule, - string? ClusterRef, - string? LastScheduleTime); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/PostgresClusterEndpoints.cs b/src/EntKube.Provisioning/Features/PostgresClusters/PostgresClusterEndpoints.cs deleted file mode 100644 index d1c8dad..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/PostgresClusterEndpoints.cs +++ /dev/null @@ -1,572 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.PostgresClusters.AddDatabase; -using EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.RestoreBackup; -using EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.PostgresClusters; - -/// -/// Maps all PostgreSQL cluster management endpoints under /api/postgres-clusters. -/// These expose the full lifecycle: create, adopt, add databases, upgrade, configure, and list. -/// -public static class PostgresClusterEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/postgres-clusters"); - - // POST /api/postgres-clusters — Create a new PostgreSQL cluster. - - group.MapPost("/", async ( - [FromBody] CreatePostgresClusterRequest request, - [FromServices] CreatePostgresClusterHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created($"/api/postgres-clusters/{result.Value}", ApiResponse.Ok(result.Value!)); - }); - - // POST /api/postgres-clusters/adopt — Discover and import existing CNPG clusters. - - group.MapPost("/adopt", async ( - [FromBody] AdoptPostgresClustersRequest request, - [FromServices] AdoptPostgresClustersHandler handler, - CancellationToken ct) => - { - Result> result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse>.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse>.Ok(result.Value!)); - }); - - // POST /api/postgres-clusters/{id}/databases — Add a database to an existing cluster. - - group.MapPost("/{id:guid}/databases", async ( - Guid id, - [FromBody] AddDatabaseApiRequest body, - [FromServices] AddDatabaseHandler handler, - CancellationToken ct) => - { - AddDatabaseRequest request = new(id, body.DatabaseName, body.OwnerRole, body.KubeConfig, body.ContextName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // POST /api/postgres-clusters/{id}/upgrade — Initiate a controlled version upgrade. - - group.MapPost("/{id:guid}/upgrade", async ( - Guid id, - [FromBody] UpgradeApiRequest body, - [FromServices] UpgradePostgresClusterHandler handler, - CancellationToken ct) => - { - UpgradePostgresClusterRequest request = new(id, body.TargetVersion, body.KubeConfig, body.ContextName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // PUT /api/postgres-clusters/{id}/configure — Reconfigure cluster settings. - - group.MapPut("/{id:guid}/configure", async ( - Guid id, - [FromBody] ConfigureApiRequest body, - [FromServices] ConfigurePostgresClusterHandler handler, - CancellationToken ct) => - { - ConfigurePostgresClusterRequest request = new( - id, body.Instances, body.StorageSize, - body.BackupSchedule, body.BackupRetentionDays, - body.KubeConfig, body.ContextName); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // GET /api/postgres-clusters — List all PostgreSQL clusters. - - group.MapGet("/", async ( - [FromQuery] Guid? clusterId, - [FromServices] IPostgresClusterRepository repository, - CancellationToken ct) => - { - IReadOnlyList clusters = clusterId.HasValue - ? await repository.GetByClusterIdAsync(clusterId.Value, ct) - : await repository.GetAllAsync(ct); - - List summaries = clusters.Select(c => new PostgresClusterSummary( - c.Id, c.ClusterId, c.Name, c.Namespace, c.PostgresVersion, - c.Instances, c.StorageSize, c.Status.ToString(), - c.Databases.ToList(), - c.BackupConfig?.Schedule, - c.BackupConfig?.RetentionDays, - c.TargetVersion)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - - // GET /api/postgres-clusters/{id} — Get a single cluster by ID. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IPostgresClusterRepository repository, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Cluster '{id}' not found.")); - } - - PostgresClusterSummary summary = new( - cluster.Id, cluster.ClusterId, cluster.Name, cluster.Namespace, - cluster.PostgresVersion, cluster.Instances, cluster.StorageSize, - cluster.Status.ToString(), cluster.Databases.ToList(), - cluster.BackupConfig?.Schedule, cluster.BackupConfig?.RetentionDays, - cluster.TargetVersion); - - return Results.Ok(ApiResponse.Ok(summary)); - }); - - // POST /api/postgres-clusters/{id}/health — Live health from K8s. - // Reads the CNPG Cluster CR and pods to get instance status, replication - // lag, WAL archiving state, and overall cluster health. - // Uses POST because the kubeConfig payload is too large for query strings. - - group.MapPost("/{id:guid}/health", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - CnpgClusterHealth? health = await cnpgClient.GetClusterHealthAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - if (health is null) - { - return Results.Ok(ApiResponse.Fail("Could not read cluster health.")); - } - - return Results.Ok(ApiResponse.Ok(health)); - }); - - // POST /api/postgres-clusters/{id}/backups — List all backups for a cluster. - // Uses POST because kubeConfig is too large for query strings. - - group.MapPost("/{id:guid}/backups", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - try - { - List backups = await cnpgClient.ListBackupsAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse>.Ok(backups)); - } - catch (Exception ex) - { - return Results.Ok(ApiResponse>.Fail( - $"Failed to list backups: {ex.Message}")); - } - }); - - // POST /api/postgres-clusters/{id}/backups/diagnose — Raw diagnostics for backup debugging. - - group.MapPost("/{id:guid}/backups/diagnose", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - try - { - BackupDiagnostics diagnostics = await cnpgClient.DiagnoseBackupsAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok(diagnostics)); - } - catch (Exception ex) - { - return Results.Ok(ApiResponse.Fail( - $"Diagnostics failed: {ex.Message}")); - } - }); - - // POST /api/postgres-clusters/{id}/backup — Trigger an on-demand backup. - - group.MapPost("/{id:guid}/backup", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await cnpgClient.TriggerBackupAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Backup triggered.")); - }); - - // POST /api/postgres-clusters/{id}/restart — Rolling restart. - - group.MapPost("/{id:guid}/restart", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await cnpgClient.RestartClusterAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Rolling restart initiated.")); - }); - - // POST /api/postgres-clusters/{id}/failover — Controlled switchover. - - group.MapPost("/{id:guid}/failover", async ( - Guid id, - [FromBody] FailoverApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await cnpgClient.FailoverAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, body.TargetInstance, ct); - - return Results.Ok(ApiResponse.Ok("Switchover initiated.")); - }); - - // DELETE /api/postgres-clusters/{id}/databases/{dbName} — Drop a database. - // Uses body for credentials since kubeConfig is too large for query strings. - - group.MapDelete("/{id:guid}/databases/{dbName}", async ( - Guid id, string dbName, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - // Remove from domain model. - - cluster.RemoveDatabase(dbName); - await repository.UpdateAsync(cluster, ct); - - // Drop on K8s. - - await cnpgClient.DeleteDatabaseAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, dbName, ct); - - return Results.Ok(ApiResponse.Ok($"Database '{dbName}' dropped.")); - }); - - // DELETE /api/postgres-clusters/{id} — Delete an entire PostgreSQL cluster. - // Removes the CNPG Cluster CR from K8s and the record from the local store. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - // Delete the CR on K8s — the operator cleans up pods, services, PVCs. - - await cnpgClient.DeleteClusterAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - // Remove from the local store. - - await repository.DeleteAsync(id, ct); - - return Results.Ok(ApiResponse.Ok($"PostgreSQL cluster '{cluster.Name}' deleted.")); - }); - - // POST /api/postgres-clusters/{id}/restore/preview — Dry-run preview of a restore. - // Shows exactly what would happen without actually creating the cluster. - - group.MapPost("/{id:guid}/restore/preview", async ( - Guid id, - [FromBody] RestorePreviewApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - // Find the source cluster to read its backup configuration. - - PostgresCluster? source = await repository.GetByIdAsync(id, ct); - - if (source is null) - { - return Results.NotFound(ApiResponse.Fail("Source cluster not found.")); - } - - // Compute what the restore would look like. - - string minioEndpoint = source.BackupConfig?.MinioEndpoint ?? "minio.minio-system.svc:9000"; - string minioBucket = source.BackupConfig?.Bucket ?? "cnpg-backups"; - string destinationPath = $"s3://{minioBucket}/{source.Name}/"; - - // Determine the recovery method based on inputs. - - string recoveryMethod = body.TargetTime.HasValue - ? "Point-in-Time Recovery (PITR)" - : body.BackupName is not null - ? $"Restore from backup '{body.BackupName}'" - : "Restore from latest backup"; - - // Build warnings for anything that might be problematic. - - List warnings = new(); - - CnpgClusterHealth? health = await cnpgClient.GetClusterHealthAsync( - body.KubeConfig, body.ContextName, source.Name, source.Namespace, ct); - - string? firstRecoverability = health?.FirstRecoverabilityPoint; - string? lastBackup = health?.LastSuccessfulBackup; - - if (body.TargetTime.HasValue && firstRecoverability is not null) - { - if (DateTimeOffset.TryParse(firstRecoverability, out DateTimeOffset earliest) - && body.TargetTime.Value < earliest) - { - warnings.Add($"Target time {body.TargetTime.Value:u} is before the earliest recovery point ({firstRecoverability}). Recovery will likely fail."); - } - } - - if (lastBackup is null) - { - warnings.Add("No successful backup recorded. Restore may fail if no base backup exists in object storage."); - } - - if (source.BackupConfig is null) - { - warnings.Add("Source cluster has no backup configuration. WAL archiving may not be enabled."); - } - - string restoredName = string.IsNullOrWhiteSpace(body.RestoredClusterName) - ? $"{source.Name}-restored" - : body.RestoredClusterName; - - RestorePreview preview = new( - SourceClusterName: source.Name, - SourceNamespace: source.Namespace, - PostgresVersion: source.PostgresVersion, - Instances: source.Instances, - StorageSize: source.StorageSize, - RestoredClusterName: restoredName, - BackupName: body.BackupName, - TargetTime: body.TargetTime, - RecoveryMethod: recoveryMethod, - MinioEndpoint: minioEndpoint, - MinioBucket: minioBucket, - DestinationPath: destinationPath, - FirstRecoverabilityPoint: firstRecoverability, - LastSuccessfulBackup: lastBackup, - Warnings: warnings); - - return Results.Ok(ApiResponse.Ok(preview)); - }); - - // POST /api/postgres-clusters/{id}/restore — Restore from a backup. - // Creates a new CNPG Cluster that bootstraps from a backup or PITR target. - - group.MapPost("/{id:guid}/restore", async ( - Guid id, - [FromBody] RestoreApiRequest body, - [FromServices] RestoreBackupHandler handler, - CancellationToken ct) => - { - RestoreBackupRequest request = new( - PostgresClusterId: id, - BackupName: body.BackupName, - RestoredClusterName: body.RestoredClusterName, - KubeConfig: body.KubeConfig, - ContextName: body.ContextName, - TargetTime: body.TargetTime); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created( - $"/api/postgres-clusters/{result.Value}", - ApiResponse.Ok(result.Value!)); - }); - - // POST /api/postgres-clusters/{id}/databases/live — List databases from the - // running PostgreSQL instance. Unlike the stored database list, this queries - // pg_database on the primary to discover all databases including ones - // created outside of EntKube. - // Uses POST because kubeConfig is too large for query strings. - - group.MapPost("/{id:guid}/databases/live", async ( - Guid id, - [FromBody] ActionApiRequest body, - [FromServices] IPostgresClusterRepository repository, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - PostgresCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - List databases = await cnpgClient.ListLiveDatabasesAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse>.Ok(databases)); - }); - - // POST /api/postgres-clusters/available-versions — List supported PostgreSQL versions. - // Used by the upgrade UI to populate the version picker. - - group.MapPost("/available-versions", async ( - [FromBody] ActionApiRequest body, - [FromServices] ICnpgClusterClient cnpgClient, - CancellationToken ct) => - { - List versions = await cnpgClient.GetAvailableVersionsAsync( - body.KubeConfig, body.ContextName, ct); - - return Results.Ok(ApiResponse>.Ok(versions)); - }); - } -} - -// API request/response DTOs for endpoints that don't map 1:1 to handler requests. - -public record AddDatabaseApiRequest(string DatabaseName, string OwnerRole, string KubeConfig, string ContextName); - -public record UpgradeApiRequest(string TargetVersion, string KubeConfig, string ContextName); - -public record ConfigureApiRequest( - int? Instances, string? StorageSize, - string? BackupSchedule, int? BackupRetentionDays, - string KubeConfig, string ContextName); - -public record ActionApiRequest(string KubeConfig, string ContextName); - -public record FailoverApiRequest(string KubeConfig, string ContextName, string? TargetInstance); - -public record RestoreApiRequest( - string? BackupName, - string RestoredClusterName, - string KubeConfig, - string ContextName, - DateTimeOffset? TargetTime); - -public record RestorePreviewApiRequest( - string? BackupName, - string? RestoredClusterName, - string KubeConfig, - string ContextName, - DateTimeOffset? TargetTime); - -public record PostgresClusterSummary( - Guid Id, Guid ClusterId, string Name, string Namespace, - string PostgresVersion, int Instances, string StorageSize, - string Status, List Databases, - string? BackupSchedule, int? BackupRetentionDays, - string? TargetVersion); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/RestoreBackup/RestoreBackupHandler.cs b/src/EntKube.Provisioning/Features/PostgresClusters/RestoreBackup/RestoreBackupHandler.cs deleted file mode 100644 index 7334b6a..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/RestoreBackup/RestoreBackupHandler.cs +++ /dev/null @@ -1,89 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters.RestoreBackup; - -/// -/// Handles restoring a PostgreSQL cluster from a backup. When a user wants to -/// recover data — whether from a scheduled backup or to a specific point in time — -/// this handler creates a brand new CNPG Cluster that bootstraps from the backup. -/// -/// The flow: -/// 1. Find the source cluster (we need its backup config and metadata) -/// 2. Build a restore specification with the backup source details -/// 3. Call the CNPG client to create the recovered cluster on K8s -/// 4. Register the new cluster in the domain as a Provisioning aggregate -/// -public class RestoreBackupHandler -{ - private readonly IPostgresClusterRepository repository; - private readonly ICnpgClusterClient cnpgClient; - - public RestoreBackupHandler(IPostgresClusterRepository repository, ICnpgClusterClient cnpgClient) - { - this.repository = repository; - this.cnpgClient = cnpgClient; - } - - public async Task> HandleAsync(RestoreBackupRequest request, CancellationToken ct = default) - { - // First, find the source cluster so we know where the backup data lives. - // We need its MinIO config, namespace, PG version, and storage settings. - - PostgresCluster? source = await repository.GetByIdAsync(request.PostgresClusterId, ct); - - if (source is null) - { - return Result.Failure($"Source cluster '{request.PostgresClusterId}' not found."); - } - - // Build the restore specification from the source cluster's config. - - RestoreFromBackupSpec spec = new( - SourceClusterName: source.Name, - RestoredClusterName: request.RestoredClusterName, - Namespace: source.Namespace, - PostgresVersion: source.PostgresVersion, - Instances: source.Instances, - StorageSize: source.StorageSize, - MinioEndpoint: source.BackupConfig?.MinioEndpoint ?? "minio.minio-system.svc:9000", - MinioBucket: source.BackupConfig?.Bucket ?? "cnpg-backups", - MinioCredentialsSecret: source.BackupConfig?.CredentialsSecret ?? "minio-cnpg-credentials", - BackupName: request.BackupName, - TargetTime: request.TargetTime, - S3Region: source.BackupConfig?.S3Region); - - // Create the recovered cluster on K8s. CNPG will pull the base backup - // and replay WAL segments from MinIO to bring it up to the target state. - - await cnpgClient.RestoreBackupAsync(request.KubeConfig, request.ContextName, spec, ct); - - // Register the restored cluster in the domain. It starts in Provisioning - // state because CNPG needs time to restore the data and start the instances. - - PostgresCluster restored = PostgresCluster.Create( - environmentId: source.EnvironmentId, - clusterId: source.ClusterId, - name: request.RestoredClusterName, - ns: source.Namespace, - postgresVersion: source.PostgresVersion, - instances: source.Instances, - storageSize: source.StorageSize, - minioEndpoint: source.BackupConfig?.MinioEndpoint ?? "minio.minio-system.svc:9000", - minioBucket: source.BackupConfig?.Bucket ?? "cnpg-backups", - minioCredentialsSecret: source.BackupConfig?.CredentialsSecret ?? "minio-cnpg-credentials", - s3Region: source.BackupConfig?.S3Region); - - await repository.AddAsync(restored, ct); - - return Result.Success(restored.Id); - } -} - -public record RestoreBackupRequest( - Guid PostgresClusterId, - string? BackupName, - string RestoredClusterName, - string KubeConfig, - string ContextName, - DateTimeOffset? TargetTime); diff --git a/src/EntKube.Provisioning/Features/PostgresClusters/UpgradePostgresCluster/UpgradePostgresClusterHandler.cs b/src/EntKube.Provisioning/Features/PostgresClusters/UpgradePostgresCluster/UpgradePostgresClusterHandler.cs deleted file mode 100644 index de5cdf1..0000000 --- a/src/EntKube.Provisioning/Features/PostgresClusters/UpgradePostgresCluster/UpgradePostgresClusterHandler.cs +++ /dev/null @@ -1,91 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster; - -/// -/// Handles controlled PostgreSQL version upgrades. Before initiating an upgrade, -/// this handler verifies the target version is actually available (supported images). -/// Then it transitions the domain to Upgrading state and tells the CNPG operator -/// to perform the rolling upgrade (switchover primary, update replicas). -/// -/// The flow: -/// 1. Find the cluster and validate it's upgradeable -/// 2. Check available versions from the K8s cluster -/// 3. Ask the domain to transition to Upgrading state -/// 4. Call the K8s client to update the image version on the CNPG Cluster -/// 5. Persist the updated aggregate -/// -/// The reconciler will later detect when the upgrade completes and call CompleteUpgrade(). -/// -public class UpgradePostgresClusterHandler -{ - private readonly IPostgresClusterRepository repository; - private readonly ICnpgClusterClient cnpgClient; - - public UpgradePostgresClusterHandler(IPostgresClusterRepository repository, ICnpgClusterClient cnpgClient) - { - this.repository = repository; - this.cnpgClient = cnpgClient; - } - - public async Task> HandleAsync(UpgradePostgresClusterRequest request, CancellationToken ct = default) - { - // Find the cluster. Can't upgrade what doesn't exist. - - PostgresCluster? pgCluster = await repository.GetByIdAsync(request.PostgresClusterId, ct); - - if (pgCluster is null) - { - return Result.Failure($"PostgreSQL cluster '{request.PostgresClusterId}' not found."); - } - - // Verify the target version is available. We ask the K8s cluster what images - // are supported so we don't try to upgrade to a non-existent version. - - List availableVersions = await cnpgClient.GetAvailableVersionsAsync( - request.KubeConfig, request.ContextName, ct); - - if (!availableVersions.Contains(request.TargetVersion)) - { - return Result.Failure( - $"Version '{request.TargetVersion}' is not available. " + - $"Available versions: {string.Join(", ", availableVersions)}"); - } - - // Ask the domain to transition. It enforces rules: must be Running, - // can't upgrade to the same version. - - Result domainResult = pgCluster.RequestUpgrade(request.TargetVersion); - - if (domainResult.IsFailure) - { - return domainResult; - } - - // Tell the CNPG operator to perform a rolling upgrade with switchover. - // It updates replicas one by one, then promotes an upgraded replica to primary - // before demoting and upgrading the old primary. If the upgrade fails on the - // demoted instance, the cluster stays healthy on the new primary. - - await cnpgClient.UpgradeClusterAsync( - request.KubeConfig, - request.ContextName, - pgCluster.Name, - pgCluster.Namespace, - request.TargetVersion, - ct); - - // Persist the updated aggregate. - - await repository.UpdateAsync(pgCluster, ct); - - return domainResult; - } -} - -public record UpgradePostgresClusterRequest( - Guid PostgresClusterId, - string TargetVersion, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceEndpoint.cs b/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceEndpoint.cs deleted file mode 100644 index ca02cd1..0000000 --- a/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceEndpoint.cs +++ /dev/null @@ -1,29 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Contracts; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.ProvisionService; - -/// -/// Maps POST /api/services — creates a new provisioning request for a shared service. -/// -public static class ProvisionServiceEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/services", async ( - [FromBody] ProvisionServiceRequest request, - [FromServices] ProvisionServiceHandler handler, - CancellationToken ct) => - { - EntKube.SharedKernel.Domain.Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created($"/api/services/{result.Value}", ApiResponse.Ok(result.Value!)); - }); - } -} diff --git a/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceHandler.cs b/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceHandler.cs deleted file mode 100644 index 099aa91..0000000 --- a/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceHandler.cs +++ /dev/null @@ -1,68 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.ProvisionService; - -/// -/// Handles requests to provision a new shared service on a cluster. A tenant admin -/// selects a service type (MinIO, CloudNativePG, Keycloak), provides a name and -/// target namespace, and we create the ServiceInstance aggregate. The actual deployment -/// is handled asynchronously by the reconciliation background service. -/// -public class ProvisionServiceHandler -{ - private readonly IServiceInstanceRepository repository; - - public ProvisionServiceHandler(IServiceInstanceRepository repository) - { - this.repository = repository; - } - - public async Task> HandleAsync(ProvisionServiceRequest request, CancellationToken ct = default) - { - // Validate the request contains all required fields. - - if (request.EnvironmentId == Guid.Empty) - { - return Result.Failure("Environment ID is required."); - } - - if (request.ClusterId == Guid.Empty) - { - return Result.Failure("Cluster ID is required."); - } - - if (string.IsNullOrWhiteSpace(request.Name)) - { - return Result.Failure("Service name is required."); - } - - if (string.IsNullOrWhiteSpace(request.Namespace)) - { - return Result.Failure("Namespace is required."); - } - - // Create the service instance aggregate. The domain enforces any invariants - // about valid service configurations. - - ServiceInstance instance = ServiceInstance.Provision( - request.EnvironmentId, - request.ServiceType, - request.Name, - request.Namespace, - request.ClusterId); - - // Persist it. The background reconciliation loop will pick it up and deploy it. - - await repository.AddAsync(instance, ct); - - return Result.Success(instance.Id); - } -} - -public record ProvisionServiceRequest( - Guid EnvironmentId, - Guid ClusterId, - ServiceType ServiceType, - string Name, - string Namespace); diff --git a/src/EntKube.Provisioning/Features/RedisClusters/AdoptRedisCluster/AdoptRedisClustersHandler.cs b/src/EntKube.Provisioning/Features/RedisClusters/AdoptRedisCluster/AdoptRedisClustersHandler.cs deleted file mode 100644 index 5363f47..0000000 --- a/src/EntKube.Provisioning/Features/RedisClusters/AdoptRedisCluster/AdoptRedisClustersHandler.cs +++ /dev/null @@ -1,84 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster; - -/// -/// Handles adoption of existing Redis instances. When a Kubernetes cluster already -/// has Redis CRs running (managed by the OT-OPERATORS operator), this handler -/// discovers them and imports them into the platform's domain. -/// -/// The flow: -/// 1. Call the K8s client to discover all Redis CRs -/// 2. Check which instances are already adopted (de-duplicate) -/// 3. For new instances, create domain aggregates in Running state -/// 4. Persist all changes -/// -public class AdoptRedisClustersHandler -{ - private readonly IRedisClusterRepository repository; - private readonly IRedisClusterClient redisClient; - - public AdoptRedisClustersHandler(IRedisClusterRepository repository, IRedisClusterClient redisClient) - { - this.repository = repository; - this.redisClient = redisClient; - } - - public async Task>> HandleAsync(AdoptRedisClustersRequest request, CancellationToken ct = default) - { - // Discover what's already running on the cluster. The K8s client reads - // all Redis CRs across all namespaces and extracts their config. - - List discovered = await redisClient.DiscoverClustersAsync( - request.KubeConfig, request.ContextName, ct); - - // Load existing adopted instances for this K8s cluster so we can - // de-duplicate. If an instance with the same name and namespace already - // exists, we return its ID instead of creating a duplicate. - - IReadOnlyList existingClusters = - await repository.GetByClusterIdAsync(request.ClusterId, ct); - - List adoptedIds = new(); - - foreach (DiscoveredRedisCluster existing in discovered) - { - // Check if this instance was already adopted. - - RedisCluster? alreadyAdopted = existingClusters.FirstOrDefault( - c => c.Name == existing.Name && c.Namespace == existing.Namespace); - - if (alreadyAdopted is not null) - { - // Already adopted — return its existing ID. - - adoptedIds.Add(alreadyAdopted.Id); - continue; - } - - // New instance — create a domain aggregate in Running state. - - RedisCluster redisCluster = RedisCluster.Adopt( - environmentId: request.EnvironmentId, - clusterId: request.ClusterId, - name: existing.Name, - ns: existing.Namespace, - redisVersion: existing.RedisVersion, - replicas: existing.Replicas, - storageSize: existing.StorageSize, - sentinelEnabled: existing.SentinelEnabled); - - await repository.AddAsync(redisCluster, ct); - adoptedIds.Add(redisCluster.Id); - } - - return Result.Success(adoptedIds); - } -} - -public record AdoptRedisClustersRequest( - Guid EnvironmentId, - Guid ClusterId, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/RedisClusters/ConfigureRedisCluster/ConfigureRedisClusterHandler.cs b/src/EntKube.Provisioning/Features/RedisClusters/ConfigureRedisCluster/ConfigureRedisClusterHandler.cs deleted file mode 100644 index 38ef244..0000000 --- a/src/EntKube.Provisioning/Features/RedisClusters/ConfigureRedisCluster/ConfigureRedisClusterHandler.cs +++ /dev/null @@ -1,96 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster; - -/// -/// Handles reconfiguration of an existing Redis instance. Supports partial updates: -/// you can change just the replica count, just sentinel mode, or any combination. -/// -/// Configurable properties: -/// - Replica count (scaling up/down) -/// - Storage size (can only grow — Kubernetes PVC limitation) -/// - Sentinel enabled/disabled (HA mode toggle) -/// -/// The flow: -/// 1. Find the cluster -/// 2. Apply domain-level changes (validates invariants) -/// 3. Call K8s client to update the Redis CR -/// 4. Persist the updated aggregate -/// -public class ConfigureRedisClusterHandler -{ - private readonly IRedisClusterRepository repository; - private readonly IRedisClusterClient redisClient; - - public ConfigureRedisClusterHandler(IRedisClusterRepository repository, IRedisClusterClient redisClient) - { - this.repository = repository; - this.redisClient = redisClient; - } - - public async Task> HandleAsync(ConfigureRedisClusterRequest request, CancellationToken ct = default) - { - // Find the target cluster. - - RedisCluster? redisCluster = await repository.GetByIdAsync(request.RedisClusterId, ct); - - if (redisCluster is null) - { - return Result.Failure($"Redis cluster '{request.RedisClusterId}' not found."); - } - - List changes = new(); - - // Apply replica scaling if requested. - - if (request.Replicas.HasValue) - { - Result scaleResult = redisCluster.ScaleReplicas(request.Replicas.Value); - - if (scaleResult.IsFailure) - { - return scaleResult; - } - - changes.Add($"replicas={request.Replicas.Value}"); - } - - // Apply sentinel toggle if requested. - - if (request.SentinelEnabled.HasValue) - { - redisCluster.ConfigureSentinel(request.SentinelEnabled.Value); - changes.Add($"sentinel={request.SentinelEnabled.Value}"); - } - - // If any infrastructure changes were made, update the Redis CR. - - if (request.Replicas.HasValue || request.StorageSize is not null || request.SentinelEnabled.HasValue) - { - RedisClusterSpec spec = new( - Name: redisCluster.Name, - Namespace: redisCluster.Namespace, - RedisVersion: redisCluster.RedisVersion, - Replicas: redisCluster.Replicas, - StorageSize: request.StorageSize ?? redisCluster.StorageSize, - SentinelEnabled: redisCluster.SentinelEnabled); - - await redisClient.UpdateClusterAsync(request.KubeConfig, request.ContextName, spec, ct); - } - - // Persist the updated aggregate. - - await repository.UpdateAsync(redisCluster, ct); - - return Result.Success($"Cluster '{redisCluster.Name}' reconfigured: {string.Join(", ", changes)}"); - } -} - -public record ConfigureRedisClusterRequest( - Guid RedisClusterId, - int? Replicas, - string? StorageSize, - bool? SentinelEnabled, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/RedisClusters/CreateRedisCluster/CreateRedisClusterHandler.cs b/src/EntKube.Provisioning/Features/RedisClusters/CreateRedisCluster/CreateRedisClusterHandler.cs deleted file mode 100644 index 17e8890..0000000 --- a/src/EntKube.Provisioning/Features/RedisClusters/CreateRedisCluster/CreateRedisClusterHandler.cs +++ /dev/null @@ -1,87 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster; - -/// -/// Handles requests to create a new Redis instance. This is the "provision from scratch" -/// path — the platform admin specifies what they want and we create both the domain -/// aggregate AND the Redis CR on Kubernetes via the OT-OPERATORS operator. -/// -/// Unlike CNPG and MongoDB, Redis doesn't require MinIO for backups. Redis persistence -/// is handled via RDB/AOF on the PVCs. Backup strategies are separate from the -/// operator lifecycle. -/// -/// The flow: -/// 1. Validate inputs (domain will enforce invariants) -/// 2. Create the RedisCluster aggregate (in Provisioning state) -/// 3. Call the K8s client to create the Redis CR -/// 4. Persist the aggregate -/// -public class CreateRedisClusterHandler -{ - private readonly IRedisClusterRepository repository; - private readonly IRedisClusterClient redisClient; - - public CreateRedisClusterHandler(IRedisClusterRepository repository, IRedisClusterClient redisClient) - { - this.repository = repository; - this.redisClient = redisClient; - } - - public async Task> HandleAsync(CreateRedisClusterRequest request, CancellationToken ct = default) - { - // Let the domain validate inputs and create the aggregate. - // If inputs are invalid, the domain throws — we catch and return a failure. - - RedisCluster redisCluster; - - try - { - redisCluster = RedisCluster.Create( - environmentId: request.EnvironmentId, - clusterId: request.ClusterId, - name: request.Name, - ns: request.Namespace, - redisVersion: request.RedisVersion, - replicas: request.Replicas, - storageSize: request.StorageSize, - sentinelEnabled: request.SentinelEnabled); - } - catch (ArgumentException ex) - { - return Result.Failure(ex.Message); - } - - // Create the Redis CR on Kubernetes. This tells the OT-OPERATORS operator - // to start deploying Redis pods with the specified configuration. - - RedisClusterSpec spec = new( - Name: redisCluster.Name, - Namespace: redisCluster.Namespace, - RedisVersion: redisCluster.RedisVersion, - Replicas: redisCluster.Replicas, - StorageSize: redisCluster.StorageSize, - SentinelEnabled: redisCluster.SentinelEnabled); - - await redisClient.CreateClusterAsync(request.KubeConfig, request.ContextName, spec, ct); - - // Persist the aggregate. The reconciler will poll its status later. - - await repository.AddAsync(redisCluster, ct); - - return Result.Success(redisCluster.Id); - } -} - -public record CreateRedisClusterRequest( - Guid EnvironmentId, - Guid ClusterId, - string Name, - string Namespace, - string RedisVersion, - int Replicas, - string StorageSize, - bool SentinelEnabled, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Features/RedisClusters/IRedisClusterClient.cs b/src/EntKube.Provisioning/Features/RedisClusters/IRedisClusterClient.cs deleted file mode 100644 index 63155ca..0000000 --- a/src/EntKube.Provisioning/Features/RedisClusters/IRedisClusterClient.cs +++ /dev/null @@ -1,132 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Features.RedisClusters; - -/// -/// Defines the contract for interacting with OT-OPERATORS Redis Operator resources -/// on a Kubernetes cluster. This interface abstracts the K8s API calls so feature -/// handlers can orchestrate domain logic without coupling to the Kubernetes client. -/// -/// The implementation will use KubernetesClient to create/patch/delete Redis custom -/// resources and read their status. -/// -/// OT-OPERATORS Redis Operator CRDs: -/// - apiVersion: redis.redis.opstreelabs.in/v1beta2 -/// - Kinds: Redis, RedisCluster, RedisReplication, RedisSentinel -/// - Manages standalone, replicated, and sentinel Redis deployments -/// -public interface IRedisClusterClient -{ - /// - /// Discovers existing Redis CRs on the K8s cluster. - /// Returns basic metadata about each instance found (name, namespace, version, replicas). - /// - Task> DiscoverClustersAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Creates a new Redis CR on K8s with the specified configuration. - /// This includes storage, replicas, and optional sentinel setup. - /// - Task CreateClusterAsync(string kubeConfig, string contextName, RedisClusterSpec spec, CancellationToken ct = default); - - /// - /// Updates an existing Redis CR (scale replicas, change resources, etc.). - /// - Task UpdateClusterAsync(string kubeConfig, string contextName, RedisClusterSpec spec, CancellationToken ct = default); - - /// - /// Initiates a version upgrade on the Redis CR by updating the image tag. - /// The operator handles the rolling update process. - /// - Task UpgradeClusterAsync(string kubeConfig, string contextName, string name, string ns, string targetVersion, CancellationToken ct = default); - - /// - /// Reads the current status of a Redis CR (phase, ready replicas, etc.). - /// - Task GetClusterStatusAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Checks what Redis versions are available for upgrade (based on available images). - /// - Task> GetAvailableVersionsAsync(string kubeConfig, string contextName, CancellationToken ct = default); - - /// - /// Reads live health details from the Redis CR and its pods, including - /// per-replica status, replication lag, memory usage, and sentinel state. - /// - Task GetClusterHealthAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Requests a rolling restart of all Redis pods by annotating the CR. - /// The operator restarts pods one by one to avoid downtime. - /// - Task RestartClusterAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Initiates a manual failover — promotes a replica to master and - /// demotes the current master. Only applicable with sentinel or replication. - /// - Task FailoverAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); - - /// - /// Deletes the Redis CR from the cluster. The operator will clean up - /// all associated pods, services, and PVCs. - /// - Task DeleteClusterAsync(string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default); -} - -/// -/// Metadata about a Redis instance discovered during adoption. -/// -public record DiscoveredRedisCluster( - string Name, - string Namespace, - string RedisVersion, - int Replicas, - string StorageSize, - bool SentinelEnabled); - -/// -/// Specification for creating or updating a Redis CR. -/// -public record RedisClusterSpec( - string Name, - string Namespace, - string RedisVersion, - int Replicas, - string StorageSize, - bool SentinelEnabled); - -/// -/// Current status of a Redis instance as reported by the operator. -/// -public record RedisCrdStatus( - string Phase, - int ReadyReplicas, - int DesiredReplicas, - string? CurrentMaster); - -/// -/// Comprehensive health snapshot of a Redis instance, combining the CR status -/// with pod-level details and replication information. -/// -public record RedisClusterHealth( - string Phase, - int ReadyReplicas, - int DesiredReplicas, - string? CurrentMaster, - string? MemoryUsage, - string? ConnectedClients, - bool SentinelActive, - List ReplicaDetails); - -/// -/// Per-replica (pod) information for a Redis instance. -/// -public record RedisReplicaInfo( - string Name, - string Role, - bool Ready, - long? ReplicationLagBytes, - string? MemoryUsed, - string? PodPhase); diff --git a/src/EntKube.Provisioning/Features/RedisClusters/RedisClusterEndpoints.cs b/src/EntKube.Provisioning/Features/RedisClusters/RedisClusterEndpoints.cs deleted file mode 100644 index 4c7e459..0000000 --- a/src/EntKube.Provisioning/Features/RedisClusters/RedisClusterEndpoints.cs +++ /dev/null @@ -1,268 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster; -using EntKube.SharedKernel.Contracts; -using EntKube.SharedKernel.Domain; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Provisioning.Features.RedisClusters; - -/// -/// Maps all Redis cluster management endpoints under /api/redis-clusters. -/// These expose the full lifecycle: create, adopt, upgrade, configure, -/// health, and operational actions (restart, failover, delete). -/// -public static class RedisClusterEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/redis-clusters"); - - // POST /api/redis-clusters — Create a new Redis instance. - - group.MapPost("/", async ( - [FromBody] CreateRedisClusterRequest request, - [FromServices] CreateRedisClusterHandler handler, - CancellationToken ct) => - { - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Created($"/api/redis-clusters/{result.Value}", ApiResponse.Ok(result.Value!)); - }); - - // POST /api/redis-clusters/adopt — Discover and import existing Redis instances. - - group.MapPost("/adopt", async ( - [FromBody] AdoptRedisClustersRequest request, - [FromServices] AdoptRedisClustersHandler handler, - CancellationToken ct) => - { - Result> result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse>.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse>.Ok(result.Value!)); - }); - - // POST /api/redis-clusters/{id}/upgrade — Initiate a controlled version upgrade. - - group.MapPost("/{id:guid}/upgrade", async ( - Guid id, - [FromBody] RedisUpgradeApiRequest body, - [FromServices] UpgradeRedisClusterHandler handler, - CancellationToken ct) => - { - UpgradeRedisClusterRequest request = new(id, body.TargetVersion, body.KubeConfig, body.ContextName); - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // PUT /api/redis-clusters/{id}/configure — Reconfigure cluster settings. - - group.MapPut("/{id:guid}/configure", async ( - Guid id, - [FromBody] RedisConfigureApiRequest body, - [FromServices] ConfigureRedisClusterHandler handler, - CancellationToken ct) => - { - ConfigureRedisClusterRequest request = new( - id, body.Replicas, body.StorageSize, body.SentinelEnabled, - body.KubeConfig, body.ContextName); - - Result result = await handler.HandleAsync(request, ct); - - if (result.IsFailure) - { - return Results.BadRequest(ApiResponse.Fail(result.Error!)); - } - - return Results.Ok(ApiResponse.Ok(result.Value!)); - }); - - // GET /api/redis-clusters — List all Redis instances. - - group.MapGet("/", async ( - [FromQuery] Guid? clusterId, - [FromServices] IRedisClusterRepository repository, - CancellationToken ct) => - { - IReadOnlyList clusters = clusterId.HasValue - ? await repository.GetByClusterIdAsync(clusterId.Value, ct) - : await repository.GetAllAsync(ct); - - List summaries = clusters.Select(c => new RedisClusterSummary( - c.Id, c.ClusterId, c.Name, c.Namespace, c.RedisVersion, - c.Replicas, c.StorageSize, c.SentinelEnabled, - c.Status.ToString(), c.TargetVersion)).ToList(); - - return Results.Ok(ApiResponse>.Ok(summaries)); - }); - - // GET /api/redis-clusters/{id} — Get a single instance by ID. - - group.MapGet("/{id:guid}", async ( - Guid id, - [FromServices] IRedisClusterRepository repository, - CancellationToken ct) => - { - RedisCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail($"Redis cluster '{id}' not found.")); - } - - RedisClusterSummary summary = new( - cluster.Id, cluster.ClusterId, cluster.Name, cluster.Namespace, - cluster.RedisVersion, cluster.Replicas, cluster.StorageSize, - cluster.SentinelEnabled, cluster.Status.ToString(), cluster.TargetVersion); - - return Results.Ok(ApiResponse.Ok(summary)); - }); - - // POST /api/redis-clusters/{id}/health — Live health from K8s. - // Reads the Redis CR and pods to get replica status, replication info, - // memory usage, and sentinel state. - - group.MapPost("/{id:guid}/health", async ( - Guid id, - [FromBody] RedisActionApiRequest body, - [FromServices] IRedisClusterRepository repository, - [FromServices] IRedisClusterClient redisClient, - CancellationToken ct) => - { - RedisCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - RedisClusterHealth? health = await redisClient.GetClusterHealthAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - if (health is null) - { - return Results.Ok(ApiResponse.Fail("Could not read cluster health.")); - } - - return Results.Ok(ApiResponse.Ok(health)); - }); - - // POST /api/redis-clusters/{id}/restart — Rolling restart. - - group.MapPost("/{id:guid}/restart", async ( - Guid id, - [FromBody] RedisActionApiRequest body, - [FromServices] IRedisClusterRepository repository, - [FromServices] IRedisClusterClient redisClient, - CancellationToken ct) => - { - RedisCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await redisClient.RestartClusterAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Rolling restart initiated.")); - }); - - // POST /api/redis-clusters/{id}/failover — Manual failover. - - group.MapPost("/{id:guid}/failover", async ( - Guid id, - [FromBody] RedisActionApiRequest body, - [FromServices] IRedisClusterRepository repository, - [FromServices] IRedisClusterClient redisClient, - CancellationToken ct) => - { - RedisCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - await redisClient.FailoverAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - return Results.Ok(ApiResponse.Ok("Failover initiated.")); - }); - - // DELETE /api/redis-clusters/{id} — Delete a Redis instance. - - group.MapDelete("/{id:guid}", async ( - Guid id, - [FromBody] RedisActionApiRequest body, - [FromServices] IRedisClusterRepository repository, - [FromServices] IRedisClusterClient redisClient, - CancellationToken ct) => - { - RedisCluster? cluster = await repository.GetByIdAsync(id, ct); - - if (cluster is null) - { - return Results.NotFound(ApiResponse.Fail("Cluster not found.")); - } - - // Delete the CR on K8s — the operator cleans up pods, services, PVCs. - - await redisClient.DeleteClusterAsync( - body.KubeConfig, body.ContextName, cluster.Name, cluster.Namespace, ct); - - // Remove from the local store. - - await repository.DeleteAsync(id, ct); - - return Results.Ok(ApiResponse.Ok($"Redis instance '{cluster.Name}' deleted.")); - }); - - // POST /api/redis-clusters/available-versions — List supported Redis versions. - - group.MapPost("/available-versions", async ( - [FromBody] RedisActionApiRequest body, - [FromServices] IRedisClusterClient redisClient, - CancellationToken ct) => - { - List versions = await redisClient.GetAvailableVersionsAsync( - body.KubeConfig, body.ContextName, ct); - - return Results.Ok(ApiResponse>.Ok(versions)); - }); - } -} - -// API request/response DTOs. - -public record RedisUpgradeApiRequest(string TargetVersion, string KubeConfig, string ContextName); - -public record RedisConfigureApiRequest( - int? Replicas, string? StorageSize, bool? SentinelEnabled, - string KubeConfig, string ContextName); - -public record RedisActionApiRequest(string KubeConfig, string ContextName); - -public record RedisClusterSummary( - Guid Id, Guid ClusterId, string Name, string Namespace, - string RedisVersion, int Replicas, string StorageSize, - bool SentinelEnabled, string Status, string? TargetVersion); diff --git a/src/EntKube.Provisioning/Features/RedisClusters/UpgradeRedisCluster/UpgradeRedisClusterHandler.cs b/src/EntKube.Provisioning/Features/RedisClusters/UpgradeRedisCluster/UpgradeRedisClusterHandler.cs deleted file mode 100644 index 6881a1d..0000000 --- a/src/EntKube.Provisioning/Features/RedisClusters/UpgradeRedisCluster/UpgradeRedisClusterHandler.cs +++ /dev/null @@ -1,84 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.SharedKernel.Domain; - -namespace EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster; - -/// -/// Handles controlled Redis version upgrades. Before initiating an upgrade, -/// this handler verifies the target version is actually available. -/// Then it transitions the domain to Upgrading state and tells the operator -/// to perform the rolling update by changing the image tag. -/// -/// The flow: -/// 1. Find the cluster and validate it's upgradeable -/// 2. Check available versions from the K8s cluster -/// 3. Ask the domain to transition to Upgrading state -/// 4. Call the K8s client to update the image version -/// 5. Persist the updated aggregate -/// -public class UpgradeRedisClusterHandler -{ - private readonly IRedisClusterRepository repository; - private readonly IRedisClusterClient redisClient; - - public UpgradeRedisClusterHandler(IRedisClusterRepository repository, IRedisClusterClient redisClient) - { - this.repository = repository; - this.redisClient = redisClient; - } - - public async Task> HandleAsync(UpgradeRedisClusterRequest request, CancellationToken ct = default) - { - // Find the cluster. Can't upgrade what doesn't exist. - - RedisCluster? redisCluster = await repository.GetByIdAsync(request.RedisClusterId, ct); - - if (redisCluster is null) - { - return Result.Failure($"Redis cluster '{request.RedisClusterId}' not found."); - } - - // Verify the target version is available. - - List availableVersions = await redisClient.GetAvailableVersionsAsync( - request.KubeConfig, request.ContextName, ct); - - if (!availableVersions.Contains(request.TargetVersion)) - { - return Result.Failure( - $"Version '{request.TargetVersion}' is not available. " + - $"Available versions: {string.Join(", ", availableVersions)}"); - } - - // Ask the domain to transition. - - Result domainResult = redisCluster.RequestUpgrade(request.TargetVersion); - - if (domainResult.IsFailure) - { - return domainResult; - } - - // Tell the operator to perform the upgrade. - - await redisClient.UpgradeClusterAsync( - request.KubeConfig, - request.ContextName, - redisCluster.Name, - redisCluster.Namespace, - request.TargetVersion, - ct); - - // Persist the updated aggregate. - - await repository.UpdateAsync(redisCluster, ct); - - return domainResult; - } -} - -public record UpgradeRedisClusterRequest( - Guid RedisClusterId, - string TargetVersion, - string KubeConfig, - string ContextName); diff --git a/src/EntKube.Provisioning/Infrastructure/EfAppRepository.cs b/src/EntKube.Provisioning/Infrastructure/EfAppRepository.cs deleted file mode 100644 index a5e937b..0000000 --- a/src/EntKube.Provisioning/Infrastructure/EfAppRepository.cs +++ /dev/null @@ -1,86 +0,0 @@ -using EntKube.Provisioning.Domain; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// EF Core implementation of the app repository. Apps are the aggregate root — -/// each load includes the related AppEnvironments (with their specs and secrets). -/// -public class EfAppRepository : IAppRepository -{ - private readonly ProvisioningDbContext db; - - public EfAppRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.Apps - .Include("environments") - .FirstOrDefaultAsync(a => a.Id == id, ct); - } - - public async Task> GetAllActiveAsync(CancellationToken ct = default) - { - return await db.Apps - .Include("environments") - .Where(a => a.Status == AppStatus.Active) - .ToListAsync(ct); - } - - public async Task> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default) - { - return await db.Apps - .Include("environments") - .Where(a => a.CustomerId == customerId) - .ToListAsync(ct); - } - - public async Task GetByCustomerAndSlugAsync(Guid customerId, string slug, CancellationToken ct = default) - { - return await db.Apps - .FirstOrDefaultAsync(a => a.CustomerId == customerId && a.Slug == slug, ct); - } - - public async Task AddAsync(App app, CancellationToken ct = default) - { - db.Apps.Add(app); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(App app, CancellationToken ct = default) - { - // When the domain adds new child entities (e.g., AppEnvironment) to - // the aggregate's private backing-field collection, EF's change tracker - // may not automatically detect them through the field-only navigation. - // We check each child and explicitly mark any untracked ones as Added - // so EF generates INSERTs instead of UPDATEs. - - foreach (AppEnvironment env in app.Environments) - { - EntityEntry entry = db.Entry(env); - - if (entry.State == EntityState.Detached) - { - db.Set().Add(env); - } - } - - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - App? app = await db.Apps.FindAsync([id], ct); - - if (app is not null) - { - db.Apps.Remove(app); - await db.SaveChangesAsync(ct); - } - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/EfRepositories.cs b/src/EntKube.Provisioning/Infrastructure/EfRepositories.cs deleted file mode 100644 index b1d6305..0000000 --- a/src/EntKube.Provisioning/Infrastructure/EfRepositories.cs +++ /dev/null @@ -1,473 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// EF Core implementations of all Provisioning repository interfaces. -/// Each repository delegates to the shared ProvisioningDbContext. -/// - -public class EfServiceInstanceRepository : IServiceInstanceRepository -{ - private readonly ProvisioningDbContext db; - - public EfServiceInstanceRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.ServiceInstances.FindAsync([id], ct); - } - - public async Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.ServiceInstances.Where(s => s.EnvironmentId == environmentId).ToListAsync(ct); - } - - public async Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.ServiceInstances.Where(s => s.ClusterId == clusterId).ToListAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.ServiceInstances.ToListAsync(ct); - } - - public async Task AddAsync(ServiceInstance instance, CancellationToken ct = default) - { - db.ServiceInstances.Add(instance); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default) - { - db.ServiceInstances.Update(instance); - await db.SaveChangesAsync(ct); - } -} - -public class EfPostgresClusterRepository : IPostgresClusterRepository -{ - private readonly ProvisioningDbContext db; - - public EfPostgresClusterRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.PostgresClusters.FindAsync([id], ct); - } - - public async Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.PostgresClusters.Where(p => p.EnvironmentId == environmentId).ToListAsync(ct); - } - - public async Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.PostgresClusters.Where(p => p.ClusterId == clusterId).ToListAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.PostgresClusters.ToListAsync(ct); - } - - public async Task AddAsync(PostgresCluster cluster, CancellationToken ct = default) - { - db.PostgresClusters.Add(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(PostgresCluster cluster, CancellationToken ct = default) - { - db.PostgresClusters.Update(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - PostgresCluster? cluster = await db.PostgresClusters.FindAsync([id], ct); - - if (cluster is not null) - { - db.PostgresClusters.Remove(cluster); - await db.SaveChangesAsync(ct); - } - } -} - -public class EfMinioInstanceRepository : IMinioInstanceRepository -{ - private readonly ProvisioningDbContext db; - - public EfMinioInstanceRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.MinioInstances.FindAsync([id], ct); - } - - public async Task GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.MinioInstances.FirstOrDefaultAsync(m => m.ClusterId == clusterId, ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.MinioInstances.ToListAsync(ct); - } - - public async Task AddAsync(MinioInstance instance, CancellationToken ct = default) - { - db.MinioInstances.Add(instance); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(MinioInstance instance, CancellationToken ct = default) - { - db.MinioInstances.Update(instance); - await db.SaveChangesAsync(ct); - } -} - -public class EfMinioTenantRepository : IMinioTenantRepository -{ - private readonly ProvisioningDbContext db; - - public EfMinioTenantRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.MinioTenants.FindAsync([id], ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.MinioTenants.ToListAsync(ct); - } - - public async Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.MinioTenants.Where(t => t.ClusterId == clusterId).ToListAsync(ct); - } - - public async Task AddAsync(MinioTenant tenant, CancellationToken ct = default) - { - db.MinioTenants.Add(tenant); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(MinioTenant tenant, CancellationToken ct = default) - { - db.MinioTenants.Update(tenant); - await db.SaveChangesAsync(ct); - } -} - -public class EfIdentityRealmRepository : IIdentityRealmRepository -{ - private readonly ProvisioningDbContext db; - - public EfIdentityRealmRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.IdentityRealms - .Include("identityProviders") - .Include("organizations") - .FirstOrDefaultAsync(r => r.Id == id, ct); - } - - public async Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.IdentityRealms - .Include("identityProviders") - .Include("organizations") - .Where(r => r.EnvironmentId == environmentId) - .ToListAsync(ct); - } - - public async Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.IdentityRealms - .Include("identityProviders") - .Include("organizations") - .Where(r => r.ClusterId == clusterId) - .ToListAsync(ct); - } - - public async Task AddAsync(IdentityRealm realm, CancellationToken ct = default) - { - db.IdentityRealms.Add(realm); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(IdentityRealm realm, CancellationToken ct = default) - { - // When a child entity (RealmIdentityProvider or RealmOrganization) is added - // to a tracked aggregate's navigation collection, EF marks it as Modified - // (not Added) because its Guid key is pre-set. We explicitly add any untracked - // child entities before saving so EF knows they are new rows. - - foreach (RealmIdentityProvider idp in realm.IdentityProviders) - { - if (db.Entry(idp).State == EntityState.Detached) - { - db.Set().Add(idp); - } - } - - foreach (RealmOrganization org in realm.Organizations) - { - if (db.Entry(org).State == EntityState.Detached) - { - db.Set().Add(org); - } - } - - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - IdentityRealm? realm = await db.IdentityRealms.FindAsync([id], ct); - - if (realm is not null) - { - db.IdentityRealms.Remove(realm); - await db.SaveChangesAsync(ct); - } - } -} - -public class EfPrometheusStackRepository : IPrometheusStackRepository -{ - private readonly ProvisioningDbContext db; - - public EfPrometheusStackRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.PrometheusStacks.FindAsync([id], ct); - } - - public async Task GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.PrometheusStacks.FirstOrDefaultAsync(p => p.ClusterId == clusterId, ct); - } - - public async Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.PrometheusStacks.Where(p => p.EnvironmentId == environmentId).ToListAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.PrometheusStacks.ToListAsync(ct); - } - - public async Task SaveAsync(PrometheusStack stack, CancellationToken ct = default) - { - bool exists = await db.PrometheusStacks.AnyAsync(p => p.Id == stack.Id, ct); - - if (exists) - { - db.PrometheusStacks.Update(stack); - } - else - { - db.PrometheusStacks.Add(stack); - } - - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - PrometheusStack? stack = await db.PrometheusStacks.FindAsync([id], ct); - - if (stack is not null) - { - db.PrometheusStacks.Remove(stack); - await db.SaveChangesAsync(ct); - } - } -} - -public class EfGrafanaInstanceRepository : IGrafanaInstanceRepository -{ - private readonly ProvisioningDbContext db; - - public EfGrafanaInstanceRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.GrafanaInstances.FindAsync([id], ct); - } - - public async Task GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.GrafanaInstances.FirstOrDefaultAsync(g => g.EnvironmentId == environmentId, ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.GrafanaInstances.ToListAsync(ct); - } - - public async Task SaveAsync(GrafanaInstance instance, CancellationToken ct = default) - { - bool exists = await db.GrafanaInstances.AnyAsync(g => g.Id == instance.Id, ct); - - if (exists) - { - db.GrafanaInstances.Update(instance); - } - else - { - db.GrafanaInstances.Add(instance); - } - - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - GrafanaInstance? instance = await db.GrafanaInstances.FindAsync([id], ct); - - if (instance is not null) - { - db.GrafanaInstances.Remove(instance); - await db.SaveChangesAsync(ct); - } - } -} - -public class EfMongoClusterRepository : IMongoClusterRepository -{ - private readonly ProvisioningDbContext db; - - public EfMongoClusterRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.MongoClusters.FindAsync([id], ct); - } - - public async Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.MongoClusters.Where(m => m.EnvironmentId == environmentId).ToListAsync(ct); - } - - public async Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.MongoClusters.Where(m => m.ClusterId == clusterId).ToListAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.MongoClusters.ToListAsync(ct); - } - - public async Task AddAsync(MongoCluster cluster, CancellationToken ct = default) - { - db.MongoClusters.Add(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(MongoCluster cluster, CancellationToken ct = default) - { - db.MongoClusters.Update(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - MongoCluster? cluster = await db.MongoClusters.FindAsync([id], ct); - - if (cluster is not null) - { - db.MongoClusters.Remove(cluster); - await db.SaveChangesAsync(ct); - } - } -} - -public class EfRedisClusterRepository : IRedisClusterRepository -{ - private readonly ProvisioningDbContext db; - - public EfRedisClusterRepository(ProvisioningDbContext db) - { - this.db = db; - } - - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return await db.RedisClusters.FindAsync([id], ct); - } - - public async Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - return await db.RedisClusters.Where(r => r.EnvironmentId == environmentId).ToListAsync(ct); - } - - public async Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - return await db.RedisClusters.Where(r => r.ClusterId == clusterId).ToListAsync(ct); - } - - public async Task> GetAllAsync(CancellationToken ct = default) - { - return await db.RedisClusters.ToListAsync(ct); - } - - public async Task AddAsync(RedisCluster cluster, CancellationToken ct = default) - { - db.RedisClusters.Add(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task UpdateAsync(RedisCluster cluster, CancellationToken ct = default) - { - db.RedisClusters.Update(cluster); - await db.SaveChangesAsync(ct); - } - - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - RedisCluster? cluster = await db.RedisClusters.FindAsync([id], ct); - - if (cluster is not null) - { - db.RedisClusters.Remove(cluster); - await db.SaveChangesAsync(ct); - } - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/HttpClusterGatewayResolver.cs b/src/EntKube.Provisioning/Infrastructure/HttpClusterGatewayResolver.cs deleted file mode 100644 index 0ff8a4c..0000000 --- a/src/EntKube.Provisioning/Infrastructure/HttpClusterGatewayResolver.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace EntKube.Provisioning.Features.Apps.Reconcile; - -/// -/// Resolves the cluster's default gateway by calling the Clusters service -/// GET /api/clusters/{id}/gateway endpoint. Results are cached per cluster -/// for the lifetime of the reconciliation cycle to avoid redundant HTTP calls -/// when multiple apps or environments target the same cluster. -/// -public class HttpClusterGatewayResolver : IClusterGatewayResolver -{ - private readonly IHttpClientFactory httpClientFactory; - private readonly ILogger logger; - - public HttpClusterGatewayResolver( - IHttpClientFactory httpClientFactory, - ILogger logger) - { - this.httpClientFactory = httpClientFactory; - this.logger = logger; - } - - /// - /// Calls the Clusters service to get the ingress gateway info for this - /// cluster. Returns null if no gateway is configured or the call fails. - /// - public async Task GetDefaultGatewayAsync(Guid clusterId, CancellationToken ct = default) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/gateway", ct); - - if (!response.IsSuccessStatusCode) - { - logger.LogWarning( - "Failed to fetch gateway info for cluster {ClusterId}: {StatusCode}", - clusterId, response.StatusCode); - return null; - } - - // Parse the ApiResponse envelope. - - JsonNode? body = await JsonNode.ParseAsync( - await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - string? provider = body?["data"]?["provider"]?.GetValue(); - - if (string.IsNullOrEmpty(provider)) - { - return null; - } - - // For Gateway API providers (Istio), extract the internal gateway ref. - - string? gatewayName = body?["data"]?["internalGateway"]?["name"]?.GetValue(); - string? gatewayNamespace = body?["data"]?["internalGateway"]?["namespace"]?.GetValue(); - - return new ResolvedGateway(provider, gatewayName, gatewayNamespace); - } - catch (Exception ex) - { - logger.LogWarning(ex, - "Error resolving gateway for cluster {ClusterId}", clusterId); - return null; - } - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/HttpSecretResolver.cs b/src/EntKube.Provisioning/Infrastructure/HttpSecretResolver.cs deleted file mode 100644 index 6a68516..0000000 --- a/src/EntKube.Provisioning/Infrastructure/HttpSecretResolver.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using EntKube.Provisioning.Features.Apps.Reconcile; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// Resolves secret values from the Secrets microservice via HTTP. -/// The Secrets service stores secrets at paths scoped by tenant. For app -/// secrets, we use the convention: apps/{customerId}/{appSlug}/{environmentId}/{vaultKey} -/// -/// The API is: GET /api/vault/{tenantId}/secrets/{path} -/// Requires a Bearer token (service token) for authentication. -/// -/// This is the production implementation — in tests, the interface is mocked. -/// -public class HttpSecretResolver : ISecretResolver -{ - private readonly IHttpClientFactory httpClientFactory; - private readonly ILogger logger; - - public HttpSecretResolver( - IHttpClientFactory httpClientFactory, - ILogger logger) - { - this.httpClientFactory = httpClientFactory; - this.logger = logger; - } - - /// - /// Calls the Secrets service to resolve a vault key to its plaintext value. - /// The secret path is constructed as: apps/{customerId}/{appSlug}/{environmentId}/{vaultKey} - /// Returns null if the key doesn't exist in the vault. - /// - public async Task ResolveAsync( - Guid tenantId, - Guid customerId, - string appSlug, - Guid environmentId, - string vaultKey, - CancellationToken ct = default) - { - // Build the secret path using the app-scoped convention. - - string path = $"apps/{customerId}/{appSlug}/{environmentId}/{vaultKey}"; - - logger.LogDebug( - "Resolving secret at path '{Path}' for tenant {TenantId}.", - path, tenantId); - - try - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.GetAsync( - $"/api/vault/{tenantId}/secrets/{path}", ct); - - if (!response.IsSuccessStatusCode) - { - // If the secret doesn't exist, return null — the reconciler - // will skip it in the generated Secret manifest. - - logger.LogWarning( - "Secret '{Path}' not found for tenant {TenantId}: {Status}", - path, tenantId, response.StatusCode); - - return null; - } - - // The Secrets service returns { path: "...", value: "...", version: N } - - JsonElement json = await response.Content.ReadFromJsonAsync(ct); - - if (json.TryGetProperty("value", out JsonElement valueElement)) - { - return valueElement.GetString(); - } - - return null; - } - catch (HttpRequestException ex) - { - logger.LogError(ex, "Failed to reach Secrets service for path '{Path}'.", path); - return null; - } - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryAppRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryAppRepository.cs deleted file mode 100644 index f6bcf77..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryAppRepository.cs +++ /dev/null @@ -1,59 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the app repository for local development and testing. -/// Production will use EF Core with PostgreSQL. -/// -public class InMemoryAppRepository : IAppRepository -{ - private readonly List apps = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - App? app = apps.FirstOrDefault(a => a.Id == id); - return Task.FromResult(app); - } - - public Task> GetAllActiveAsync(CancellationToken ct = default) - { - IReadOnlyList result = apps.Where(a => a.Status == AppStatus.Active).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default) - { - IReadOnlyList result = apps.Where(a => a.CustomerId == customerId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task GetByCustomerAndSlugAsync(Guid customerId, string slug, CancellationToken ct = default) - { - App? app = apps.FirstOrDefault(a => a.CustomerId == customerId && a.Slug == slug); - return Task.FromResult(app); - } - - public Task AddAsync(App app, CancellationToken ct = default) - { - apps.Add(app); - return Task.CompletedTask; - } - - public Task UpdateAsync(App app, CancellationToken ct = default) - { - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - App? app = apps.FirstOrDefault(a => a.Id == id); - - if (app is not null) - { - apps.Remove(app); - } - - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryGrafanaInstanceRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryGrafanaInstanceRepository.cs deleted file mode 100644 index 0bbca61..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryGrafanaInstanceRepository.cs +++ /dev/null @@ -1,51 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the Grafana instance repository for local development. -/// -public class InMemoryGrafanaInstanceRepository : IGrafanaInstanceRepository -{ - private readonly List instances = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - GrafanaInstance? instance = instances.FirstOrDefault(i => i.Id == id); - return Task.FromResult(instance); - } - - public Task GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - GrafanaInstance? instance = instances.FirstOrDefault(i => i.EnvironmentId == environmentId); - return Task.FromResult(instance); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = instances.AsReadOnly(); - return Task.FromResult(result); - } - - public Task SaveAsync(GrafanaInstance instance, CancellationToken ct = default) - { - int index = instances.FindIndex(i => i.Id == instance.Id); - - if (index >= 0) - { - instances[index] = instance; - } - else - { - instances.Add(instance); - } - - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - instances.RemoveAll(i => i.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryIdentityRealmRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryIdentityRealmRepository.cs deleted file mode 100644 index 5b01f11..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryIdentityRealmRepository.cs +++ /dev/null @@ -1,54 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the identity realm repository. -/// Used for development and testing. Production will use EF Core. -/// -public class InMemoryIdentityRealmRepository : IIdentityRealmRepository -{ - private readonly List realms = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - IdentityRealm? realm = realms.FirstOrDefault(r => r.Id == id); - return Task.FromResult(realm); - } - - public Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - IReadOnlyList result = realms - .Where(r => r.EnvironmentId == environmentId) - .ToList() - .AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - IReadOnlyList result = realms - .Where(r => r.ClusterId == clusterId) - .ToList() - .AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(IdentityRealm realm, CancellationToken ct = default) - { - realms.Add(realm); - return Task.CompletedTask; - } - - public Task UpdateAsync(IdentityRealm realm, CancellationToken ct = default) - { - // In-memory: the object reference is already in the list, so no action needed. - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - realms.RemoveAll(r => r.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryMinioInstanceRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryMinioInstanceRepository.cs deleted file mode 100644 index 3b8f0f7..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryMinioInstanceRepository.cs +++ /dev/null @@ -1,40 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the MinIO instance repository for development/testing. -/// -public class InMemoryMinioInstanceRepository : IMinioInstanceRepository -{ - private readonly List instances = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - MinioInstance? instance = instances.FirstOrDefault(i => i.Id == id); - return Task.FromResult(instance); - } - - public Task GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - MinioInstance? instance = instances.FirstOrDefault(i => i.ClusterId == clusterId); - return Task.FromResult(instance); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = instances.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(MinioInstance instance, CancellationToken ct = default) - { - instances.Add(instance); - return Task.CompletedTask; - } - - public Task UpdateAsync(MinioInstance instance, CancellationToken ct = default) - { - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryMinioTenantRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryMinioTenantRepository.cs deleted file mode 100644 index 557ee4d..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryMinioTenantRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the MinIO tenant repository. -/// Used for development and testing. Production will use EF Core. -/// -public class InMemoryMinioTenantRepository : IMinioTenantRepository -{ - private readonly List tenants = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - MinioTenant? tenant = tenants.FirstOrDefault(t => t.Id == id); - return Task.FromResult(tenant); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = tenants.AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - IReadOnlyList result = tenants - .Where(t => t.ClusterId == clusterId) - .ToList() - .AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(MinioTenant tenant, CancellationToken ct = default) - { - tenants.Add(tenant); - return Task.CompletedTask; - } - - public Task UpdateAsync(MinioTenant tenant, CancellationToken ct = default) - { - // In-memory — the object reference is already updated. - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryMongoClusterRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryMongoClusterRepository.cs deleted file mode 100644 index fc0fa18..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryMongoClusterRepository.cs +++ /dev/null @@ -1,52 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the MongoCluster repository for development/testing. -/// -public class InMemoryMongoClusterRepository : IMongoClusterRepository -{ - private readonly List clusters = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - MongoCluster? cluster = clusters.FirstOrDefault(c => c.Id == id); - return Task.FromResult(cluster); - } - - public Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - IReadOnlyList result = clusters.Where(c => c.EnvironmentId == environmentId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - IReadOnlyList result = clusters.Where(c => c.ClusterId == clusterId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = clusters.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(MongoCluster cluster, CancellationToken ct = default) - { - clusters.Add(cluster); - return Task.CompletedTask; - } - - public Task UpdateAsync(MongoCluster cluster, CancellationToken ct = default) - { - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - clusters.RemoveAll(c => c.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryPostgresClusterRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryPostgresClusterRepository.cs deleted file mode 100644 index b2c87c5..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryPostgresClusterRepository.cs +++ /dev/null @@ -1,52 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the PostgresCluster repository for development/testing. -/// -public class InMemoryPostgresClusterRepository : IPostgresClusterRepository -{ - private readonly List clusters = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - PostgresCluster? cluster = clusters.FirstOrDefault(c => c.Id == id); - return Task.FromResult(cluster); - } - - public Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - IReadOnlyList result = clusters.Where(c => c.EnvironmentId == environmentId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - IReadOnlyList result = clusters.Where(c => c.ClusterId == clusterId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = clusters.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(PostgresCluster cluster, CancellationToken ct = default) - { - clusters.Add(cluster); - return Task.CompletedTask; - } - - public Task UpdateAsync(PostgresCluster cluster, CancellationToken ct = default) - { - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - clusters.RemoveAll(c => c.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryPrometheusStackRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryPrometheusStackRepository.cs deleted file mode 100644 index 337f1b8..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryPrometheusStackRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the Prometheus stack repository for local development. -/// -public class InMemoryPrometheusStackRepository : IPrometheusStackRepository -{ - private readonly List stacks = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - PrometheusStack? stack = stacks.FirstOrDefault(s => s.Id == id); - return Task.FromResult(stack); - } - - public Task GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - PrometheusStack? stack = stacks.FirstOrDefault(s => s.ClusterId == clusterId); - return Task.FromResult(stack); - } - - public Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - IReadOnlyList result = stacks.Where(s => s.EnvironmentId == environmentId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = stacks.AsReadOnly(); - return Task.FromResult(result); - } - - public Task SaveAsync(PrometheusStack stack, CancellationToken ct = default) - { - int index = stacks.FindIndex(s => s.Id == stack.Id); - - if (index >= 0) - { - stacks[index] = stack; - } - else - { - stacks.Add(stack); - } - - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - stacks.RemoveAll(s => s.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryRedisClusterRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryRedisClusterRepository.cs deleted file mode 100644 index 79c1353..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryRedisClusterRepository.cs +++ /dev/null @@ -1,52 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of IRedisClusterRepository for testing. -/// -public class InMemoryRedisClusterRepository : IRedisClusterRepository -{ - private readonly List clusters = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - RedisCluster? cluster = clusters.FirstOrDefault(c => c.Id == id); - return Task.FromResult(cluster); - } - - public Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - IReadOnlyList result = clusters.Where(c => c.EnvironmentId == environmentId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - IReadOnlyList result = clusters.Where(c => c.ClusterId == clusterId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = clusters.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(RedisCluster cluster, CancellationToken ct = default) - { - clusters.Add(cluster); - return Task.CompletedTask; - } - - public Task UpdateAsync(RedisCluster cluster, CancellationToken ct = default) - { - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - clusters.RemoveAll(c => c.Id == id); - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryServiceInstanceRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryServiceInstanceRepository.cs deleted file mode 100644 index 7428e5a..0000000 --- a/src/EntKube.Provisioning/Infrastructure/InMemoryServiceInstanceRepository.cs +++ /dev/null @@ -1,47 +0,0 @@ -using EntKube.Provisioning.Domain; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// In-memory implementation of the service instance repository for local development. -/// Production will use EF Core with PostgreSQL. -/// -public class InMemoryServiceInstanceRepository : IServiceInstanceRepository -{ - private readonly List instances = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - ServiceInstance? instance = instances.FirstOrDefault(i => i.Id == id); - return Task.FromResult(instance); - } - - public Task> GetByEnvironmentIdAsync(Guid environmentId, CancellationToken ct = default) - { - IReadOnlyList result = instances.Where(i => i.EnvironmentId == environmentId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) - { - IReadOnlyList result = instances.Where(i => i.ClusterId == clusterId).ToList().AsReadOnly(); - return Task.FromResult(result); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - IReadOnlyList result = instances.AsReadOnly(); - return Task.FromResult(result); - } - - public Task AddAsync(ServiceInstance instance, CancellationToken ct = default) - { - instances.Add(instance); - return Task.CompletedTask; - } - - public Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default) - { - return Task.CompletedTask; - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/KubernetesApplyClient.cs b/src/EntKube.Provisioning/Infrastructure/KubernetesApplyClient.cs deleted file mode 100644 index fe0b149..0000000 --- a/src/EntKube.Provisioning/Infrastructure/KubernetesApplyClient.cs +++ /dev/null @@ -1,524 +0,0 @@ -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.Reconcile; -using k8s; -using k8s.Models; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// Applies Kubernetes manifests to a target cluster by fetching the cluster's -/// kubeconfig from the Clusters service, building a K8s client, and using -/// server-side apply. For Helm operations, shells out to the helm CLI. -/// -/// The flow for each apply: -/// 1. Call Clusters service GET /api/clusters/{id}/credentials -/// 2. Build a Kubernetes client from the kubeconfig + contextName -/// 3. Parse the YAML to extract kind, apiVersion, metadata (name, namespace) -/// 4. Try to get the resource — if it exists, patch it; if not, create it -/// -/// This is the production implementation — in tests, the interface is mocked. -/// -public class KubernetesApplyClient : IKubernetesApplyClient -{ - private readonly IHttpClientFactory httpClientFactory; - private readonly ILogger logger; - - public KubernetesApplyClient( - IHttpClientFactory httpClientFactory, - ILogger logger) - { - this.httpClientFactory = httpClientFactory; - this.logger = logger; - } - - /// - /// Applies a YAML manifest to the target cluster. Fetches the kubeconfig - /// from the Clusters service, builds a K8s client, and uses server-side - /// apply (patch with MergePatch) to create or update the resource. - /// - public async Task ApplyManifestAsync(Guid clusterId, string yaml, CancellationToken ct = default) - { - // Step 1: Fetch kubeconfig from the Clusters service. - - ClusterCredentials creds = await GetClusterCredentialsAsync(clusterId, ct); - - // Step 2: Build a Kubernetes client from the kubeconfig. - - Kubernetes client = BuildClient(creds.KubeConfig, creds.ContextName); - - // Step 3: Parse the YAML to extract resource metadata. We need - // the kind, apiVersion, name, and namespace to route the API call. - - ManifestMetadata meta = ParseManifestMetadata(yaml); - - logger.LogInformation( - "Applying {Kind} '{Name}' in namespace '{Namespace}' to cluster {ClusterId}.", - meta.Kind, meta.Name, meta.Namespace, clusterId); - - // Step 4: Convert YAML to JSON for the K8s API. - - string json = ConvertYamlToJson(yaml); - - // Step 5: Apply using the appropriate API call based on the resource kind. - // We use the upsert pattern: try Get, if NotFound → Create, else → Patch. - - await ApplyResourceAsync(client, meta, json, ct); - } - - /// - /// Installs or upgrades a Helm release on the target cluster. Writes the - /// kubeconfig to a temp file and runs helm upgrade --install via CLI. - /// - public async Task HelmUpgradeInstallAsync( - Guid clusterId, - string releaseName, - string ns, - HelmReleaseSpec spec, - CancellationToken ct = default) - { - ClusterCredentials creds = await GetClusterCredentialsAsync(clusterId, ct); - - // Write kubeconfig and values to temp files so the helm CLI can use them. - - string kubeconfigPath = Path.GetTempFileName(); - string? valuesPath = null; - - try - { - await File.WriteAllTextAsync(kubeconfigPath, creds.KubeConfig, ct); - - // Build the helm command arguments. - - List args = new() - { - "upgrade", "--install", releaseName, spec.ChartName, - "--repo", spec.RepoUrl, - "--version", spec.ChartVersion, - "--namespace", ns, - "--create-namespace", - "--kubeconfig", kubeconfigPath, - "--kube-context", creds.ContextName, - "--wait", - "--timeout", "10m" - }; - - if (!string.IsNullOrWhiteSpace(spec.ValuesYaml)) - { - valuesPath = Path.GetTempFileName(); - await File.WriteAllTextAsync(valuesPath, spec.ValuesYaml, ct); - args.Add("-f"); - args.Add(valuesPath); - } - - logger.LogInformation( - "Running: helm {Args}", - string.Join(" ", args.Select(a => a.Contains(' ') ? $"\"{a}\"" : a))); - - // Execute helm CLI. - - System.Diagnostics.ProcessStartInfo psi = new("helm") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - foreach (string arg in args) - { - psi.ArgumentList.Add(arg); - } - - using System.Diagnostics.Process? process = System.Diagnostics.Process.Start(psi); - - if (process is null) - { - throw new InvalidOperationException("Failed to start helm process."); - } - - string stdout = await process.StandardOutput.ReadToEndAsync(ct); - string stderr = await process.StandardError.ReadToEndAsync(ct); - await process.WaitForExitAsync(ct); - - if (process.ExitCode != 0) - { - logger.LogError("Helm failed (exit {Code}): {Stderr}", process.ExitCode, stderr); - throw new InvalidOperationException($"Helm upgrade --install failed: {stderr}"); - } - - logger.LogInformation("Helm succeeded: {Stdout}", stdout.Trim()); - } - finally - { - // Clean up temp files — they contain sensitive kubeconfig data. - - if (File.Exists(kubeconfigPath)) - { - File.Delete(kubeconfigPath); - } - - if (valuesPath is not null && File.Exists(valuesPath)) - { - File.Delete(valuesPath); - } - } - } - - // ─── Private helpers ───────────────────────────────────────────────── - - /// - /// Fetches kubeconfig and contextName from the Clusters service. - /// This is an internal service-to-service call. - /// - private async Task GetClusterCredentialsAsync(Guid clusterId, CancellationToken ct) - { - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct); - - if (!response.IsSuccessStatusCode) - { - throw new InvalidOperationException( - $"Failed to fetch credentials for cluster {clusterId}: {response.StatusCode}"); - } - - // The Clusters service returns ApiResponse with - // { success: true, data: { kubeConfig: "...", contextName: "..." } } - - JsonElement json = await response.Content.ReadFromJsonAsync(ct); - JsonElement data = json.GetProperty("data"); - - string kubeConfig = data.GetProperty("kubeConfig").GetString() - ?? throw new InvalidOperationException("KubeConfig is null in credentials response."); - - string contextName = data.GetProperty("contextName").GetString() - ?? throw new InvalidOperationException("ContextName is null in credentials response."); - - return new ClusterCredentials(kubeConfig, contextName); - } - - /// - /// Builds a Kubernetes client from kubeconfig string + context name. - /// Same pattern used by KubernetesCnpgClusterClient and KubernetesMinioClient. - /// - private static Kubernetes BuildClient(string kubeConfig, string contextName) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(kubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, contextName); - return new Kubernetes(config); - } - - /// - /// Parses the YAML manifest to extract kind, apiVersion, name, and namespace. - /// Uses simple string parsing — we don't need a full YAML parser for this. - /// - private static ManifestMetadata ParseManifestMetadata(string yaml) - { - string? kind = null; - string? apiVersion = null; - string? name = null; - string? ns = null; - bool inMetadata = false; - - foreach (string line in yaml.Split('\n')) - { - string trimmed = line.Trim(); - - if (trimmed.StartsWith("kind:")) - { - kind = trimmed["kind:".Length..].Trim(); - } - else if (trimmed.StartsWith("apiVersion:")) - { - apiVersion = trimmed["apiVersion:".Length..].Trim(); - } - else if (trimmed == "metadata:") - { - inMetadata = true; - } - else if (inMetadata && trimmed.StartsWith("name:")) - { - name = trimmed["name:".Length..].Trim(); - } - else if (inMetadata && trimmed.StartsWith("namespace:")) - { - ns = trimmed["namespace:".Length..].Trim(); - } - else if (inMetadata && !trimmed.StartsWith("") && !line.StartsWith(" ") && !line.StartsWith("\t") && trimmed.Length > 0) - { - inMetadata = false; - } - } - - return new ManifestMetadata( - kind ?? throw new InvalidOperationException("YAML manifest missing 'kind'"), - apiVersion ?? throw new InvalidOperationException("YAML manifest missing 'apiVersion'"), - name ?? throw new InvalidOperationException("YAML manifest missing 'metadata.name'"), - ns); - } - - /// - /// Converts YAML to JSON using the YamlDotNet-based serializer from the K8s client. - /// Since we generate simple YAML, a basic approach works fine. - /// - private static string ConvertYamlToJson(string yaml) - { - YamlDotNet.Serialization.Deserializer deserializer = new(); - object? yamlObject = deserializer.Deserialize(yaml); - return JsonSerializer.Serialize(yamlObject); - } - - /// - /// Applies a single resource to the cluster using the upsert pattern: - /// try to get the existing resource, then create or patch accordingly. - /// Routes to the correct API based on kind and apiVersion. - /// - private static async Task ApplyResourceAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - // For core resources (Deployment, Service, Secret), use the typed APIs. - // For CRDs (HTTPRoute), use the generic CustomObjects API. - - switch (meta.Kind) - { - case "Deployment": - await UpsertDeploymentAsync(client, meta, json, ct); - break; - - case "Service": - await UpsertServiceAsync(client, meta, json, ct); - break; - - case "Secret": - await UpsertSecretAsync(client, meta, json, ct); - break; - - case "Namespace": - await UpsertNamespaceAsync(client, meta, json, ct); - break; - - case "ConfigMap": - await UpsertConfigMapAsync(client, meta, json, ct); - break; - - case "PersistentVolumeClaim": - await UpsertPvcAsync(client, meta, json, ct); - break; - - case "ServiceAccount": - await UpsertServiceAccountAsync(client, meta, json, ct); - break; - - case "HTTPRoute": - case "TLSRoute": - case "TCPRoute": - case "UDPRoute": - case "GRPCRoute": - await UpsertCustomResourceAsync(client, meta, json, ct); - break; - - default: - throw new InvalidOperationException($"Unsupported resource kind: {meta.Kind}"); - } - } - - private static async Task UpsertDeploymentAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - string ns = meta.Namespace ?? "default"; - - try - { - await client.AppsV1.ReadNamespacedDeploymentAsync(meta.Name, ns, cancellationToken: ct); - - // Exists — patch it. - - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.AppsV1.PatchNamespacedDeploymentAsync(patch, meta.Name, ns, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Doesn't exist — create it. - - V1Deployment deployment = KubernetesJson.Deserialize(json); - await client.AppsV1.CreateNamespacedDeploymentAsync(deployment, ns, cancellationToken: ct); - } - } - - private static async Task UpsertServiceAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - string ns = meta.Namespace ?? "default"; - - try - { - await client.CoreV1.ReadNamespacedServiceAsync(meta.Name, ns, cancellationToken: ct); - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespacedServiceAsync(patch, meta.Name, ns, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Service service = KubernetesJson.Deserialize(json); - await client.CoreV1.CreateNamespacedServiceAsync(service, ns, cancellationToken: ct); - } - } - - private static async Task UpsertSecretAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - string ns = meta.Namespace ?? "default"; - - try - { - await client.CoreV1.ReadNamespacedSecretAsync(meta.Name, ns, cancellationToken: ct); - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespacedSecretAsync(patch, meta.Name, ns, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Secret secret = KubernetesJson.Deserialize(json); - await client.CoreV1.CreateNamespacedSecretAsync(secret, ns, cancellationToken: ct); - } - } - - private static async Task UpsertNamespaceAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(meta.Name, cancellationToken: ct); - - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespaceAsync(patch, meta.Name, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = KubernetesJson.Deserialize(json); - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - } - } - - private static async Task UpsertConfigMapAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - string ns = meta.Namespace ?? "default"; - - try - { - await client.CoreV1.ReadNamespacedConfigMapAsync(meta.Name, ns, cancellationToken: ct); - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespacedConfigMapAsync(patch, meta.Name, ns, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1ConfigMap configMap = KubernetesJson.Deserialize(json); - await client.CoreV1.CreateNamespacedConfigMapAsync(configMap, ns, cancellationToken: ct); - } - } - - private static async Task UpsertPvcAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - string ns = meta.Namespace ?? "default"; - - try - { - await client.CoreV1.ReadNamespacedPersistentVolumeClaimAsync(meta.Name, ns, cancellationToken: ct); - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespacedPersistentVolumeClaimAsync(patch, meta.Name, ns, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1PersistentVolumeClaim pvc = KubernetesJson.Deserialize(json); - await client.CoreV1.CreateNamespacedPersistentVolumeClaimAsync(pvc, ns, cancellationToken: ct); - } - } - - private static async Task UpsertServiceAccountAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - string ns = meta.Namespace ?? "default"; - - try - { - await client.CoreV1.ReadNamespacedServiceAccountAsync(meta.Name, ns, cancellationToken: ct); - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CoreV1.PatchNamespacedServiceAccountAsync(patch, meta.Name, ns, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1ServiceAccount sa = KubernetesJson.Deserialize(json); - await client.CoreV1.CreateNamespacedServiceAccountAsync(sa, ns, cancellationToken: ct); - } - } - - /// - /// Lists service account names in the given namespace on the target cluster. - /// - public async Task> ListServiceAccountsAsync( - Guid clusterId, string ns, CancellationToken ct = default) - { - ClusterCredentials creds = await GetClusterCredentialsAsync(clusterId, ct); - Kubernetes client = BuildClient(creds.KubeConfig, creds.ContextName); - - V1ServiceAccountList saList = await client.CoreV1 - .ListNamespacedServiceAccountAsync(ns, cancellationToken: ct); - - return saList.Items.Select(sa => sa.Metadata.Name).ToList(); - } - - /// - /// Lists PersistentVolumeClaims in the given namespace on the target cluster. - /// - public async Task> ListPvcsAsync( - Guid clusterId, string ns, CancellationToken ct = default) - { - ClusterCredentials creds = await GetClusterCredentialsAsync(clusterId, ct); - Kubernetes client = BuildClient(creds.KubeConfig, creds.ContextName); - - V1PersistentVolumeClaimList pvcList = await client.CoreV1 - .ListNamespacedPersistentVolumeClaimAsync(ns, cancellationToken: ct); - - return pvcList.Items.Select(pvc => new PvcSummary( - Name: pvc.Metadata.Name, - StorageClass: pvc.Spec.StorageClassName, - Capacity: pvc.Status?.Capacity?.ContainsKey("storage") == true - ? pvc.Status.Capacity["storage"].ToString() - : null, - Status: pvc.Status?.Phase ?? "Unknown")).ToList(); - } - - private static async Task UpsertCustomResourceAsync( - Kubernetes client, ManifestMetadata meta, string json, CancellationToken ct) - { - // Parse apiVersion into group and version (e.g., "gateway.networking.k8s.io/v1" → group + v1). - - string[] parts = meta.ApiVersion.Split('/'); - string group = parts.Length > 1 ? parts[0] : ""; - string version = parts.Length > 1 ? parts[1] : parts[0]; - string plural = meta.Kind.ToLowerInvariant() + "s"; - string ns = meta.Namespace ?? "default"; - - try - { - await client.CustomObjects.GetNamespacedCustomObjectAsync( - group, version, ns, plural, meta.Name, cancellationToken: ct); - - V1Patch patch = new(json, V1Patch.PatchType.MergePatch); - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - patch, group, version, ns, plural, meta.Name, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - JsonElement body = JsonSerializer.Deserialize(json); - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body, group, version, ns, plural, cancellationToken: ct); - } - } - - private record ClusterCredentials(string KubeConfig, string ContextName); - private record ManifestMetadata(string Kind, string ApiVersion, string Name, string? Namespace); -} diff --git a/src/EntKube.Provisioning/Infrastructure/KubernetesCnpgClusterClient.cs b/src/EntKube.Provisioning/Infrastructure/KubernetesCnpgClusterClient.cs deleted file mode 100644 index 533441e..0000000 --- a/src/EntKube.Provisioning/Infrastructure/KubernetesCnpgClusterClient.cs +++ /dev/null @@ -1,2519 +0,0 @@ -using System.Net.WebSockets; -using System.Text; -using System.Text.Json; -using EntKube.Provisioning.Features.PostgresClusters; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// Implementation of ICnpgClusterClient that talks to the Kubernetes API using -/// the official .NET KubernetesClient. Manages CNPG custom resources: -/// -/// - postgresql.cnpg.io/v1 Cluster — the main PostgreSQL cluster resource -/// - postgresql.cnpg.io/v1 ScheduledBackup — cron-based backups -/// -/// The CNPG operator watches these CRs and reconciles them into running PostgreSQL -/// instances with WAL archiving, replication, and automated failover. -/// -public class KubernetesCnpgClusterClient : ICnpgClusterClient -{ - private readonly ILogger logger; - - private const string CnpgGroup = "postgresql.cnpg.io"; - private const string CnpgVersion = "v1"; - private const string ClusterPlural = "clusters"; - private const string ScheduledBackupPlural = "scheduledbackups"; - - public KubernetesCnpgClusterClient(ILogger logger) - { - this.logger = logger; - } - - public async Task> DiscoverClustersAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // Connect to the K8s cluster and list all CNPG Cluster CRs across all namespaces. - // Each CR represents a running PostgreSQL cluster managed by the operator. - - Kubernetes client = BuildClient(kubeConfig, contextName); - List discovered = new(); - - try - { - object result = await client.CustomObjects.ListClusterCustomObjectAsync( - group: CnpgGroup, - version: CnpgVersion, - plural: ClusterPlural, - cancellationToken: ct); - - // Parse the raw JSON response to extract cluster details. - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (!json.TryGetProperty("items", out JsonElement items)) - { - return discovered; - } - - foreach (JsonElement item in items.EnumerateArray()) - { - DiscoveredPostgresCluster? cluster = ParseDiscoveredCluster(item); - - if (cluster is not null) - { - discovered.Add(cluster); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to discover CNPG clusters"); - } - - return discovered; - } - - public async Task CreateClusterAsync( - string kubeConfig, string contextName, CnpgClusterSpec spec, CancellationToken ct = default) - { - // Build the CNPG Cluster manifest with WAL archiving to MinIO and create it. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - // Ensure the target namespace exists before creating the CNPG resources. - // If the user specified a namespace that doesn't exist yet, we create it - // with the managed-by label so EntKube can track it. - - await EnsureNamespaceAsync(client, spec.Namespace, ct); - - // When backup is configured, the Cluster CR references a credentials secret - // by name. CNPG resolves this secret from the SAME namespace as the Cluster — - // NOT from cnpg-system where the installer originally creates it. We copy the - // secret into the cluster's namespace so barman-cloud can authenticate with S3. - - if (spec.HasBackupConfig) - { - await EnsureBackupSecretInNamespaceAsync( - client, spec.MinioCredentialsSecret!, spec.Namespace, ct, - accessKey: spec.S3AccessKey, - secretKey: spec.S3SecretKey, - region: spec.S3Region, - endpoint: spec.MinioEndpoint, - bucket: spec.MinioBucket); - } - - object clusterManifest = BuildClusterManifest(spec); - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: clusterManifest, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: spec.Namespace, - plural: ClusterPlural, - cancellationToken: ct); - - // Also create the ScheduledBackup resource, but only when backup is configured. - // If a backup with the same name already exists (e.g. re-creating a cluster - // after a failed attempt), we replace it instead of failing with 409 Conflict. - - if (spec.HasBackupConfig) - { - object backupManifest = BuildScheduledBackupManifest(spec); - string backupName = $"{spec.Name}-scheduled-backup"; - - try - { - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: backupManifest, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: spec.Namespace, - plural: ScheduledBackupPlural, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - // The ScheduledBackup already exists — delete it and recreate - // with the current spec so schedule/retention changes take effect. - - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: spec.Namespace, - plural: ScheduledBackupPlural, - name: backupName, - cancellationToken: ct); - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: backupManifest, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: spec.Namespace, - plural: ScheduledBackupPlural, - cancellationToken: ct); - } - } - - logger.LogInformation( - "Created CNPG Cluster '{Name}' in namespace '{Namespace}' with {Instances} instances", - spec.Name, spec.Namespace, spec.Instances); - } - - public async Task UpdateClusterAsync( - string kubeConfig, string contextName, CnpgClusterSpec spec, CancellationToken ct = default) - { - // Patch the existing CNPG Cluster resource with updated values. - // We use a merge patch to only update the fields we specify. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - // Build the base patch with instance count, storage, and image. - - Dictionary specPatch = new() - { - ["instances"] = spec.Instances, - ["storage"] = new { size = spec.StorageSize }, - ["imageName"] = $"ghcr.io/cloudnative-pg/postgresql:{spec.PostgresVersion}" - }; - - string patchJson = JsonSerializer.Serialize(new { spec = specPatch }); - k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: spec.Namespace, - plural: ClusterPlural, - name: spec.Name, - cancellationToken: ct); - - logger.LogInformation("Updated CNPG Cluster '{Name}': instances={Instances}, storage={Storage}", - spec.Name, spec.Instances, spec.StorageSize); - } - - public async Task UpgradeClusterAsync( - string kubeConfig, string contextName, string name, string ns, - string targetVersion, CancellationToken ct = default) - { - // Upgrade means changing the image tag on the Cluster resource. We explicitly - // set primaryUpdateStrategy to "unsupervised" so the operator handles it - // automatically, and primaryUpdateMethod to "switchover" so CNPG promotes an - // already-upgraded replica to primary before touching the old primary. - // - // This is a rolling upgrade: replicas are updated one by one, then a switchover - // moves traffic to an upgraded replica. The old primary is demoted and upgraded - // last. If the upgrade fails on the demoted instance, the cluster stays healthy - // because the new primary is already running the target version. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - object patchBody = new - { - spec = new - { - imageName = $"ghcr.io/cloudnative-pg/postgresql:{targetVersion}", - primaryUpdateStrategy = "unsupervised", - primaryUpdateMethod = "switchover" - } - }; - - string patchJson = JsonSerializer.Serialize(patchBody); - k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: ns, - plural: ClusterPlural, - name: name, - cancellationToken: ct); - - logger.LogInformation("Initiated rolling upgrade of CNPG Cluster '{Name}' to PostgreSQL {Version} with switchover", - name, targetVersion); - } - - public async Task CreateDatabaseAsync( - string kubeConfig, string contextName, string clusterName, string ns, - string databaseName, string ownerRole, CancellationToken ct = default) - { - // Creating a database on a running CNPG cluster. We create a Kubernetes Job - // that runs psql against the primary pod to create the role and database. - // This is more reliable than patching the Cluster spec for existing clusters. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string primaryService = $"{clusterName}-rw"; - - k8s.Models.V1Job job = new() - { - Metadata = new k8s.Models.V1ObjectMeta - { - Name = $"create-db-{databaseName}-{Guid.NewGuid():N}"[..63], - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "create-database" - } - }, - Spec = new k8s.Models.V1JobSpec - { - Template = new k8s.Models.V1PodTemplateSpec - { - Spec = new k8s.Models.V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "create-db", - Image = "postgres:16-alpine", - Command = new List { "sh", "-c" }, - Args = new List - { - $"PGPASSWORD=$POSTGRES_PASSWORD psql -h {primaryService} -U postgres -c " + - $"\"CREATE ROLE {ownerRole} LOGIN;\" -c " + - $"\"CREATE DATABASE {databaseName} OWNER {ownerRole};\"" - }, - Env = new List - { - new() - { - Name = "POSTGRES_PASSWORD", - ValueFrom = new k8s.Models.V1EnvVarSource - { - SecretKeyRef = new k8s.Models.V1SecretKeySelector - { - Name = $"{clusterName}-superuser", - Key = "password" - } - } - } - } - } - } - } - }, - BackoffLimit = 3 - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - logger.LogInformation("Created database '{Database}' with owner '{Owner}' on cluster '{Cluster}'", - databaseName, ownerRole, clusterName); - } - - public async Task GetClusterStatusAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - object result = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: ns, - plural: ClusterPlural, - name: name, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (!json.TryGetProperty("status", out JsonElement status)) - { - return null; - } - - string phase = status.TryGetProperty("phase", out JsonElement phaseEl) - ? phaseEl.GetString() ?? "Unknown" - : "Unknown"; - - int ready = status.TryGetProperty("readyInstances", out JsonElement readyEl) - ? readyEl.GetInt32() - : 0; - - int total = status.TryGetProperty("instances", out JsonElement totalEl) - ? totalEl.GetInt32() - : 0; - - string? primary = status.TryGetProperty("currentPrimary", out JsonElement primaryEl) - ? primaryEl.GetString() - : null; - - string? latestWal = status.TryGetProperty("latestWAL", out JsonElement walEl) - ? walEl.GetString() - : null; - - return new CnpgClusterStatus(phase, ready, total, primary, latestWal, null); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to get status of CNPG Cluster '{Name}'", name); - return null; - } - } - - public async Task ConfigureScheduledBackupAsync( - string kubeConfig, string contextName, string clusterName, string ns, - string schedule, int retentionDays, CancellationToken ct = default) - { - // Patch the ScheduledBackup resource with the new schedule and retention. - - Kubernetes client = BuildClient(kubeConfig, contextName); - string backupName = $"{clusterName}-scheduled-backup"; - - object patchBody = new - { - spec = new - { - schedule = schedule, - backupOwnerReference = "cluster", - cluster = new { name = clusterName }, - method = "barmanObjectStore", - retentionPolicy = $"{retentionDays}d" - } - }; - - string patchJson = JsonSerializer.Serialize(patchBody); - k8s.Models.V1Patch patch = new(patchJson, k8s.Models.V1Patch.PatchType.MergePatch); - - try - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: ns, - plural: ScheduledBackupPlural, - name: backupName, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // ScheduledBackup doesn't exist yet — create it. - - object backupManifest = new - { - apiVersion = $"{CnpgGroup}/{CnpgVersion}", - kind = "ScheduledBackup", - metadata = new - { - name = backupName, - @namespace = ns, - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - spec = new - { - schedule = schedule, - backupOwnerReference = "cluster", - cluster = new { name = clusterName }, - method = "barmanObjectStore", - retentionPolicy = $"{retentionDays}d" - } - }; - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: backupManifest, - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: ns, - plural: ScheduledBackupPlural, - cancellationToken: ct); - } - - logger.LogInformation( - "Configured backup for cluster '{Cluster}': schedule={Schedule}, retention={Retention}d", - clusterName, schedule, retentionDays); - } - - public Task> GetAvailableVersionsAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // In production this would query available container images or a version registry. - // For now, we return the supported PostgreSQL versions that CNPG supports. - - List versions = new() - { - "13.14", "13.15", "13.16", - "14.11", "14.12", "14.13", - "15.6", "15.7", "15.8", - "16.2", "16.3", "16.4", "16.5", - "17.0", "17.1" - }; - - return Task.FromResult(versions); - } - - // ─── New live health / backup / action methods ──────────────────────── - - public async Task GetClusterHealthAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Read the CNPG Cluster CR and its pods to build a comprehensive health snapshot. - // CNPG stores rich status data directly on the Cluster resource. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - object result = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: ClusterPlural, name: name, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (!json.TryGetProperty("status", out JsonElement status)) - { - return null; - } - - string phase = GetStringProp(status, "phase") ?? "Unknown"; - int ready = GetIntProp(status, "readyInstances"); - int total = GetIntProp(status, "instances"); - string? currentPrimary = GetStringProp(status, "currentPrimary"); - string? currentPrimaryTs = GetStringProp(status, "currentPrimaryTimestamp"); - string? targetPrimary = GetStringProp(status, "targetPrimary"); - // CNPG uses "timeLineID" (capital L) in its JSON output, - // but some versions may use "timelineID". Try both. - - int timeline = status.TryGetProperty("timeLineID", out JsonElement clusterTlEl) && clusterTlEl.ValueKind == JsonValueKind.Number - ? clusterTlEl.GetInt32() - : GetIntProp(status, "timelineID", 1); - string? latestWal = GetStringProp(status, "latestWAL"); - string? firstRecovery = GetStringProp(status, "firstRecoverabilityPoint"); - string? lastBackup = GetStringProp(status, "lastSuccessfulBackup"); - - // Parse per-instance status. CNPG provides this under status.instancesStatus - // or we can read from the pods directly. - - List instances = ParseInstancesStatus(status, client, name, ns, ct); - - // CNPG's CRD status only reports isPrimary and timeLineID per instance. - // To get LSN positions, replication lag, and WAL archive info, we query - // pg_stat_replication + pg_stat_archiver directly on the primary pod. - - string? liveLatestWal = await EnrichInstancesWithReplicationDataAsync(instances, client, name, ns, ct); - - // Build replication summary from instance data. - - CnpgReplicationInfo? replication = BuildReplicationInfo(instances); - - // Use the CRD field if present, otherwise fall back to what we queried - // from pg_stat_archiver (the last successfully archived WAL segment). - - string? effectiveLatestWal = latestWal ?? liveLatestWal; - - // Estimate storage from instance names (read PVC sizes). - - string? storageUsed = null; - string? walStorageUsed = null; - - if (status.TryGetProperty("pvcCount", out JsonElement pvcCountEl)) - { - // CNPG tracks PVC count — we can't read actual used space without metrics. - // Just note total PVC count for now. - } - - return new CnpgClusterHealth( - Phase: phase, - ReadyInstances: ready, - TotalInstances: total, - CurrentPrimary: currentPrimary, - CurrentPrimaryTimestamp: currentPrimaryTs, - TargetPrimary: targetPrimary, - CurrentTimeline: timeline, - LatestWalArchived: effectiveLatestWal, - FirstRecoverabilityPoint: firstRecovery, - LastSuccessfulBackup: lastBackup, - StorageUsed: storageUsed, - WalStorageUsed: walStorageUsed, - Instances: instances, - Replication: replication); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to get health of CNPG Cluster '{Name}'", name); - return null; - } - } - - public async Task> ListBackupsAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // List all Backup CRs that belong to this cluster. CNPG creates Backup CRs - // for both scheduled and on-demand backups. We first search in the cluster's - // namespace, then fall back to a cluster-wide search if nothing is found — - // because some setups place Backup CRs in a different namespace. - - Kubernetes client = BuildClient(kubeConfig, contextName); - List backups = new(); - - try - { - // Step 1: Search in the same namespace as the cluster. - - logger.LogInformation( - "Listing backups for CNPG cluster '{Name}' in namespace '{Namespace}'", name, ns); - - backups = await ListBackupsInScopeAsync(client, name, ns, namespaced: true, ct); - - logger.LogInformation( - "Found {Count} backup(s) in namespace '{Namespace}' for cluster '{Name}'", - backups.Count, ns, name); - - // Step 2: If no backups found in the namespace, search cluster-wide. - // Some CNPG setups or backup tools create Backup CRs elsewhere. - - if (backups.Count == 0) - { - logger.LogInformation( - "No backups in namespace '{Namespace}', searching cluster-wide for cluster '{Name}'", - ns, name); - - backups = await ListBackupsInScopeAsync(client, name, ns, namespaced: false, ct); - - logger.LogInformation( - "Found {Count} backup(s) cluster-wide for cluster '{Name}'", - backups.Count, name); - } - - // Most recent first. - - backups.Sort((a, b) => (b.StartedAt ?? DateTimeOffset.MinValue) - .CompareTo(a.StartedAt ?? DateTimeOffset.MinValue)); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list backups for CNPG Cluster '{Name}' in '{Namespace}'", name, ns); - } - - return backups; - } - - /// - /// Lists backup CRs either in a specific namespace or cluster-wide, then filters - /// by cluster name. Logs each backup found for diagnostic purposes. - /// - private async Task> ListBackupsInScopeAsync( - Kubernetes client, string clusterName, string ns, bool namespaced, CancellationToken ct) - { - List backups = new(); - - object result = namespaced - ? await client.CustomObjects.ListNamespacedCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: "backups", - cancellationToken: ct) - : await client.CustomObjects.ListClusterCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - plural: "backups", - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (!json.TryGetProperty("items", out JsonElement items)) - { - logger.LogWarning("Backup list response has no 'items' property"); - return backups; - } - - int totalItems = 0; - - foreach (JsonElement item in items.EnumerateArray()) - { - totalItems++; - - // Extract the backup's metadata name for logging. - - string? backupName = null; - string? backupNs = null; - - if (item.TryGetProperty("metadata", out JsonElement meta)) - { - backupName = GetStringProp(meta, "name"); - backupNs = GetStringProp(meta, "namespace"); - } - - // Determine which cluster this backup belongs to. - - string? backupClusterName = ExtractBackupClusterName(item); - - logger.LogDebug( - "Backup CR '{BackupName}' in ns '{BackupNs}' references cluster '{BackupCluster}' (looking for '{TargetCluster}')", - backupName, backupNs, backupClusterName ?? "(null)", clusterName); - - if (backupClusterName is null) - { - // As a last-resort heuristic, check if the backup name starts with - // the cluster name. CNPG ScheduledBackup uses this naming pattern: - // - or - - - if (backupName is not null && - backupName.StartsWith(clusterName, StringComparison.OrdinalIgnoreCase)) - { - logger.LogInformation( - "Backup '{BackupName}' matched cluster '{ClusterName}' via name prefix heuristic", - backupName, clusterName); - } - else - { - continue; - } - } - else if (!backupClusterName.Equals(clusterName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - CnpgBackupInfo? backup = ParseBackup(item); - - if (backup is not null) - { - backups.Add(backup); - } - } - - logger.LogInformation( - "Scanned {Total} backup CR(s), {Matched} matched cluster '{ClusterName}'", - totalItems, backups.Count, clusterName); - - return backups; - } - - /// - /// Extracts the cluster name from a Backup CR. CNPG has used different field - /// structures across versions, so we try multiple paths: - /// 1. spec.cluster.name (CNPG v1.20+) - /// 2. spec.cluster as a plain string (older CNPG) - /// 3. metadata.labels["cnpg.io/cluster"] - /// 4. metadata.labels["postgresql"] - /// - private static string? ExtractBackupClusterName(JsonElement item) - { - // Path 1: spec.cluster.name (object reference). - - if (item.TryGetProperty("spec", out JsonElement spec)) - { - if (spec.TryGetProperty("cluster", out JsonElement cluster)) - { - if (cluster.ValueKind == JsonValueKind.Object && - cluster.TryGetProperty("name", out JsonElement nameEl)) - { - return nameEl.GetString(); - } - - // Path 2: spec.cluster as a plain string. - - if (cluster.ValueKind == JsonValueKind.String) - { - return cluster.GetString(); - } - } - } - - // Path 3: Labels on the metadata. - - if (item.TryGetProperty("metadata", out JsonElement metadata) && - metadata.TryGetProperty("labels", out JsonElement labels)) - { - if (labels.TryGetProperty("cnpg.io/cluster", out JsonElement cnpgLabel)) - { - return cnpgLabel.GetString(); - } - - if (labels.TryGetProperty("postgresql", out JsonElement pgLabel)) - { - return pgLabel.GetString(); - } - } - - return null; - } - - public async Task DiagnoseBackupsAsync( - string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default) - { - // This method gathers raw, unfiltered data about backups for debugging. - // It reads the Cluster CR's status (firstRecoverabilityPoint, lastSuccessfulBackup), - // lists ALL Backup CRs (unfiltered), and lists all ScheduledBackup CRs. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string? firstRecovery = null; - string? lastBackup = null; - List allBackups = new(); - List scheduledBackups = new(); - int namespacedCount = 0; - int clusterWideCount = 0; - - try - { - // Read the cluster's own status for backup info. - - object clusterResult = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: ClusterPlural, name: clusterName, - cancellationToken: ct); - - JsonElement clusterJson = JsonSerializer.SerializeToElement(clusterResult); - - if (clusterJson.TryGetProperty("status", out JsonElement status)) - { - firstRecovery = GetStringProp(status, "firstRecoverabilityPoint"); - lastBackup = GetStringProp(status, "lastSuccessfulBackup"); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "DiagnoseBackups: Failed to read Cluster CR '{Name}'", clusterName); - } - - // List ALL Backup CRs in the namespace (no filtering). - - try - { - object result = await client.CustomObjects.ListNamespacedCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: "backups", - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (json.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement item in items.EnumerateArray()) - { - namespacedCount++; - allBackups.Add(ExtractBackupCRSummary(item)); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "DiagnoseBackups: Failed to list namespaced Backup CRs in '{Namespace}'", ns); - } - - // List ALL Backup CRs cluster-wide (no filtering). - - try - { - object result = await client.CustomObjects.ListClusterCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - plural: "backups", - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (json.TryGetProperty("items", out JsonElement items)) - { - clusterWideCount = 0; - - foreach (JsonElement item in items.EnumerateArray()) - { - clusterWideCount++; - - // Only add if not already captured from namespaced listing. - - string? bName = item.TryGetProperty("metadata", out JsonElement m) - ? GetStringProp(m, "name") : null; - string? bNs = item.TryGetProperty("metadata", out JsonElement m2) - ? GetStringProp(m2, "namespace") : null; - - if (bNs is not null && !bNs.Equals(ns, StringComparison.OrdinalIgnoreCase)) - { - allBackups.Add(ExtractBackupCRSummary(item)); - } - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "DiagnoseBackups: Failed to list cluster-wide Backup CRs"); - } - - // List ScheduledBackup CRs. - - try - { - object result = await client.CustomObjects.ListNamespacedCustomObjectAsync( - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: ScheduledBackupPlural, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (json.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement item in items.EnumerateArray()) - { - scheduledBackups.Add(ExtractScheduledBackupSummary(item)); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "DiagnoseBackups: Failed to list ScheduledBackup CRs in '{Namespace}'", ns); - } - - return new BackupDiagnostics( - ClusterName: clusterName, - Namespace: ns, - FirstRecoverabilityPoint: firstRecovery, - LastSuccessfulBackup: lastBackup, - TotalBackupCRsInNamespace: namespacedCount, - TotalBackupCRsClusterWide: clusterWideCount, - AllBackupCRs: allBackups, - ScheduledBackups: scheduledBackups); - } - - private static BackupCRSummary ExtractBackupCRSummary(JsonElement item) - { - string? name = null; - string? itemNs = null; - string? phase = null; - string? method = null; - DateTimeOffset? started = null; - DateTimeOffset? completed = null; - - if (item.TryGetProperty("metadata", out JsonElement meta)) - { - name = GetStringProp(meta, "name"); - itemNs = GetStringProp(meta, "namespace"); - } - - string? clusterRef = ExtractBackupClusterName(item); - - if (item.TryGetProperty("spec", out JsonElement spec)) - { - method = GetStringProp(spec, "method"); - } - - if (item.TryGetProperty("status", out JsonElement status)) - { - phase = GetStringProp(status, "phase"); - - if (status.TryGetProperty("startedAt", out JsonElement startEl) && - DateTimeOffset.TryParse(startEl.GetString(), out DateTimeOffset startDt)) - { - started = startDt; - } - - if (status.TryGetProperty("stoppedAt", out JsonElement stopEl) && - DateTimeOffset.TryParse(stopEl.GetString(), out DateTimeOffset stopDt)) - { - completed = stopDt; - } - else if (status.TryGetProperty("completedAt", out JsonElement compEl) && - DateTimeOffset.TryParse(compEl.GetString(), out DateTimeOffset compDt)) - { - completed = compDt; - } - } - - return new BackupCRSummary( - Name: name ?? "(unknown)", - Namespace: itemNs, - Phase: phase, - ClusterRef: clusterRef, - Method: method, - StartedAt: started, - CompletedAt: completed); - } - - private static ScheduledBackupSummary ExtractScheduledBackupSummary(JsonElement item) - { - string? name = null; - string? itemNs = null; - string? schedule = null; - string? clusterRef = null; - string? lastSchedule = null; - - if (item.TryGetProperty("metadata", out JsonElement meta)) - { - name = GetStringProp(meta, "name"); - itemNs = GetStringProp(meta, "namespace"); - } - - if (item.TryGetProperty("spec", out JsonElement spec)) - { - schedule = GetStringProp(spec, "schedule"); - - if (spec.TryGetProperty("cluster", out JsonElement cluster)) - { - clusterRef = cluster.ValueKind == JsonValueKind.Object - ? GetStringProp(cluster, "name") - : (cluster.ValueKind == JsonValueKind.String ? cluster.GetString() : null); - } - } - - if (item.TryGetProperty("status", out JsonElement status)) - { - lastSchedule = GetStringProp(status, "lastScheduleTime"); - } - - return new ScheduledBackupSummary( - Name: name ?? "(unknown)", - Namespace: itemNs, - Schedule: schedule, - ClusterRef: clusterRef, - LastScheduleTime: lastSchedule); - } - - public async Task TriggerBackupAsync( - string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default) - { - // Create an on-demand Backup CR. The CNPG operator picks this up and - // performs an immediate base backup to the configured object store. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string backupName = $"{clusterName}-manual-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"; - - object backupManifest = new - { - apiVersion = $"{CnpgGroup}/{CnpgVersion}", - kind = "Backup", - metadata = new - { - name = backupName, - @namespace = ns, - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "manual-backup" - } - }, - spec = new - { - cluster = new { name = clusterName }, - method = "barmanObjectStore" - } - }; - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: backupManifest, - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: "backups", - cancellationToken: ct); - - logger.LogInformation("Triggered on-demand backup '{Backup}' for cluster '{Cluster}'", - backupName, clusterName); - } - - public async Task DeleteDatabaseAsync( - string kubeConfig, string contextName, string clusterName, string ns, - string databaseName, CancellationToken ct = default) - { - // Drop the database by running a psql Job against the primary. - // We first terminate all connections to the database, then drop it. - - Kubernetes client = BuildClient(kubeConfig, contextName); - string primaryService = $"{clusterName}-rw"; - - V1Job job = new() - { - Metadata = new V1ObjectMeta - { - Name = $"drop-db-{databaseName}-{Guid.NewGuid():N}"[..63], - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "delete-database" - } - }, - Spec = new V1JobSpec - { - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "drop-db", - Image = "postgres:16-alpine", - Command = new List { "sh", "-c" }, - Args = new List - { - $"PGPASSWORD=$POSTGRES_PASSWORD psql -h {primaryService} -U postgres -c " + - $"\"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='{databaseName}';\" -c " + - $"\"DROP DATABASE IF EXISTS \\\"{databaseName}\\\";\"" - }, - Env = new List - { - new() - { - Name = "POSTGRES_PASSWORD", - ValueFrom = new V1EnvVarSource - { - SecretKeyRef = new V1SecretKeySelector - { - Name = $"{clusterName}-superuser", - Key = "password" - } - } - } - } - } - } - } - }, - BackoffLimit = 3 - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - logger.LogInformation("Initiated drop of database '{Database}' on cluster '{Cluster}'", - databaseName, clusterName); - } - - public async Task RestartClusterAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // CNPG supports rolling restart via an annotation on the Cluster resource. - // Setting kubectl.kubernetes.io/restartedAt triggers a controlled restart - // of all instances, one by one, starting with replicas then the primary. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string patchJson = JsonSerializer.Serialize(new - { - metadata = new - { - annotations = new Dictionary - { - ["kubectl.kubernetes.io/restartedAt"] = DateTimeOffset.UtcNow.ToString("o") - } - } - }); - - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: ClusterPlural, name: name, - cancellationToken: ct); - - logger.LogInformation("Initiated rolling restart of CNPG Cluster '{Name}'", name); - } - - public async Task DeleteClusterAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Delete the CNPG Cluster CR. The operator cleans up pods, services, PVCs, - // ScheduledBackups, and all other owned resources. - // If the resource is already gone from the cluster (404), we treat that as success. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: CnpgGroup, - version: CnpgVersion, - namespaceParameter: ns, - plural: ClusterPlural, - name: name, - cancellationToken: ct); - - logger.LogInformation("Deleted CNPG Cluster '{Name}' from namespace '{Namespace}'", name, ns); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogInformation("CNPG Cluster '{Name}' already removed from namespace '{Namespace}'", name, ns); - } - } - - public async Task FailoverAsync( - string kubeConfig, string contextName, string name, string ns, - string? targetInstance, CancellationToken ct = default) - { - // CNPG supports controlled switchover by patching the Cluster's - // spec.primaryUpdateStrategy and setting the target primary. - // If no target is specified, CNPG picks the best replica. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - object patchBody; - - if (targetInstance is not null) - { - patchBody = new - { - status = new - { - targetPrimary = targetInstance - } - }; - } - else - { - // Read current status to find a suitable replica. - - object result = await client.CustomObjects.GetNamespacedCustomObjectAsync( - CnpgGroup, CnpgVersion, ns, ClusterPlural, name, cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - string? current = GetStringProp( - json.TryGetProperty("status", out JsonElement s) ? s : default, "currentPrimary"); - - // Pick first ready instance that isn't the primary. - - string? candidate = null; - - if (json.TryGetProperty("status", out JsonElement statusEl) && - statusEl.TryGetProperty("instancesReportedState", out JsonElement stateEl)) - { - foreach (JsonProperty inst in stateEl.EnumerateObject()) - { - if (inst.Name != current) - { - candidate = inst.Name; - break; - } - } - } - - patchBody = new - { - status = new - { - targetPrimary = candidate ?? current - } - }; - } - - string patchJson = JsonSerializer.Serialize(patchBody); - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectStatusAsync( - body: patch, - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: ns, plural: ClusterPlural, name: name, - cancellationToken: ct); - - logger.LogInformation("Initiated switchover for CNPG Cluster '{Name}', target: {Target}", - name, targetInstance ?? "(auto)"); - } - - // ─── Health parsing helpers ─────────────────────────────────────────── - - private List ParseInstancesStatus( - JsonElement status, Kubernetes client, string clusterName, string ns, CancellationToken ct) - { - List instances = new(); - - // CNPG provides two complementary views of instance health: - // - status.instancesStatus: categorised lists ("healthy", "ready", "replicating") - // - status.instancesReportedState: per-pod details (LSN, role, WAL receiver) - // - // We build a set of "known healthy" pod names from instancesStatus first, - // then merge the detailed per-pod info from instancesReportedState. - - HashSet healthyPods = new(StringComparer.OrdinalIgnoreCase); - - if (status.TryGetProperty("instancesStatus", out JsonElement instStatus)) - { - // CNPG groups pods by category. Pods in "healthy", "ready", or - // "replicating" are considered ready. The only non-ready categories - // are "failed" or the catch-all. - - foreach (JsonProperty category in instStatus.EnumerateObject()) - { - bool categoryHealthy = category.Name is "healthy" or "ready" or "replicating"; - - foreach (JsonElement podName in category.Value.EnumerateArray()) - { - string? pod = podName.GetString(); - - if (pod is not null && categoryHealthy) - { - healthyPods.Add(pod); - } - } - } - } - - if (!status.TryGetProperty("instancesReportedState", out JsonElement reportedState)) - { - // No detailed state available — fall back to the category-based view. - - if (status.TryGetProperty("instancesStatus", out JsonElement fallbackStatus)) - { - string? primaryName = GetStringProp(status, "currentPrimary"); - - foreach (JsonProperty category in fallbackStatus.EnumerateObject()) - { - bool categoryHealthy = category.Name is "healthy" or "ready" or "replicating"; - - foreach (JsonElement podName in category.Value.EnumerateArray()) - { - string pod = podName.GetString() ?? "unknown"; - bool isPrimary = pod == primaryName; - - instances.Add(new CnpgInstanceInfo( - Name: pod, - Role: isPrimary ? "primary" : "replica", - Status: categoryHealthy ? "healthy" : category.Name, - Ready: categoryHealthy, - CurrentLsn: null, ReceivedLsn: null, ReplayedLsn: null, - ReplicationLagBytes: null, TimelineId: null, PodPhase: null)); - } - } - } - - return instances; - } - - string? primary = GetStringProp(status, "currentPrimary"); - - foreach (JsonProperty inst in reportedState.EnumerateObject()) - { - JsonElement state = inst.Value; - bool isPrimary = inst.Name == primary; - - string? currentLsn = GetStringProp(state, "currentLsn"); - string? receivedLsn = GetStringProp(state, "receivedLsn"); - string? replayedLsn = GetStringProp(state, "replayedLsn"); - long? lagBytes = state.TryGetProperty("replayLag", out JsonElement lagEl) && lagEl.ValueKind == JsonValueKind.Number - ? lagEl.GetInt64() - : null; - // CNPG uses "timeLineID" (capital L) in Go struct tags. - // Try both casings for compatibility across versions. - - string? timelineId = null; - - if (state.TryGetProperty("timeLineID", out JsonElement tlEl) || state.TryGetProperty("timelineID", out tlEl)) - { - timelineId = tlEl.ToString(); - } - - // A pod is ready if: - // 1. It appears in the "healthy"/"ready"/"replicating" category, OR - // 2. It's the primary and reports isPrimary=true, OR - // 3. It's a replica that is actively receiving WAL (has LSN data). - - bool ready; - - if (healthyPods.Contains(inst.Name)) - { - ready = true; - } - else if (isPrimary) - { - ready = true; - } - else - { - // Replica: if it has received or replayed LSN data, it's streaming. - - ready = receivedLsn is not null || replayedLsn is not null; - } - - instances.Add(new CnpgInstanceInfo( - Name: inst.Name, - Role: isPrimary ? "primary" : "replica", - Status: ready ? "healthy" : "not-ready", - Ready: ready, - CurrentLsn: currentLsn, - ReceivedLsn: receivedLsn, - ReplayedLsn: replayedLsn, - ReplicationLagBytes: lagBytes, - TimelineId: timelineId, - PodPhase: null)); - } - - return instances; - } - - /// - /// Enriches instance info with replication data queried directly from PostgreSQL. - /// CNPG's CRD status only reports isPrimary and timeLineID per instance — it does - /// NOT include LSN positions or replication lag. To surface that data in the UI, - /// we exec into the primary pod and query pg_stat_replication + pg_stat_archiver, - /// which gives us LSN positions, lag, and the last archived WAL segment name. - /// - /// Returns the last successfully archived WAL segment name (from pg_stat_archiver), - /// or null if it couldn't be retrieved. - /// - private async Task EnrichInstancesWithReplicationDataAsync( - List instances, Kubernetes client, string clusterName, string ns, CancellationToken ct) - { - // Find the primary pod so we can run SQL against it. - - string? primaryPod = await FindPrimaryPodAsync(client, clusterName, ns, ct); - - if (primaryPod is null) - { - logger.LogDebug("Cannot enrich replication data — no primary pod found for '{Cluster}'", clusterName); - return null; - } - - try - { - // Run three queries in a single psql call: - // 1. The primary's current WAL position (pg_current_wal_lsn) - // 2. Per-replica replication status from pg_stat_replication - // 3. The last successfully archived WAL segment from pg_stat_archiver - // - // We prefix each row with a tag so we can distinguish them when parsing. - - string[] command = new[] - { - "psql", "-U", "postgres", "-t", "-A", "-F", "|", "-c", - "SELECT 'PRIMARY_LSN', pg_current_wal_lsn()::text " + - "UNION ALL " + - "SELECT 'REPL_' || application_name, " + - "sent_lsn::text || '|' || write_lsn::text || '|' || " + - "flush_lsn::text || '|' || replay_lsn::text || '|' || " + - "coalesce(pg_wal_lsn_diff(sent_lsn, replay_lsn)::bigint::text, '') " + - "FROM pg_stat_replication " + - "UNION ALL " + - "SELECT 'LAST_ARCHIVED_WAL', coalesce(last_archived_wal, '') FROM pg_stat_archiver;" - }; - - string output = await ExecInPodAsync(client, primaryPod, ns, "postgres", command, ct); - - // Parse results. Each line is "tag|value" separated by our custom delimiter. - - string? primaryLsn = null; - string? lastArchivedWal = null; - Dictionary replicationData = new(StringComparer.OrdinalIgnoreCase); - - foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - string[] parts = line.Split('|'); - - if (parts.Length >= 2 && parts[0] == "PRIMARY_LSN") - { - // Row: PRIMARY_LSN | - - primaryLsn = parts[1]; - } - else if (parts.Length >= 2 && parts[0] == "LAST_ARCHIVED_WAL") - { - // Row: LAST_ARCHIVED_WAL | - - string wal = parts[1].Trim(); - - if (!string.IsNullOrEmpty(wal)) - { - lastArchivedWal = wal; - } - } - else if (parts.Length >= 2 && parts[0].StartsWith("REPL_", StringComparison.Ordinal)) - { - // Row: REPL_ | |||| - // The value column itself contains pipe-separated sub-fields. - - string appName = parts[0]["REPL_".Length..]; - string[] repl = parts[1].Split('|'); - - if (repl.Length >= 5) - { - string? sentLsn = string.IsNullOrEmpty(repl[0]) ? null : repl[0]; - string? writeLsn = string.IsNullOrEmpty(repl[1]) ? null : repl[1]; - string? replayLsn = string.IsNullOrEmpty(repl[3]) ? null : repl[3]; - long? lagBytes = long.TryParse(repl[4], out long lb) ? lb : null; - - replicationData[appName] = (sentLsn, writeLsn, replayLsn, lagBytes); - } - } - } - - // Merge the data back into the instance list. CnpgInstanceInfo is a record, - // so we create new instances using 'with' expressions to replace the fields. - - for (int i = 0; i < instances.Count; i++) - { - CnpgInstanceInfo inst = instances[i]; - - if (inst.Role == "primary") - { - // The primary's "current LSN" is its WAL write position. - - if (primaryLsn is not null) - { - instances[i] = inst with { CurrentLsn = primaryLsn }; - } - } - else if (replicationData.TryGetValue(inst.Name, out (string? SentLsn, string? WriteLsn, string? ReplayLsn, long? LagBytes) data)) - { - // For replicas, write_lsn is what the replica wrote to disk (confirmed receipt). - // replay_lsn is what it has actually applied. The lag is sent minus replayed. - - instances[i] = inst with - { - ReceivedLsn = data.WriteLsn ?? data.SentLsn, - ReplayedLsn = data.ReplayLsn, - ReplicationLagBytes = data.LagBytes - }; - } - } - - logger.LogDebug( - "Enriched replication data for cluster '{Cluster}': primary LSN={Lsn}, {ReplicaCount} replica(s) matched, lastArchivedWAL={Wal}", - clusterName, primaryLsn, replicationData.Count, lastArchivedWal); - - return lastArchivedWal; - } - catch (Exception ex) - { - // Replication data is informational — if we can't get it, log and continue. - // The basic instance info (pod name, role, ready) is still valid. - - logger.LogDebug(ex, "Failed to enrich replication data for cluster '{Cluster}'", clusterName); - - return null; - } - } - - private static CnpgReplicationInfo? BuildReplicationInfo(List instances) - { - if (instances.Count < 2) - { - return null; - } - - bool streaming = instances.Any(i => i.Role == "replica" && i.Ready); - int syncReplicas = instances.Count(i => i.Role == "replica" && i.Ready); - long maxLag = instances - .Where(i => i.ReplicationLagBytes.HasValue) - .Select(i => i.ReplicationLagBytes!.Value) - .DefaultIfEmpty(0) - .Max(); - - return new CnpgReplicationInfo(streaming, syncReplicas, maxLag); - } - - private static CnpgBackupInfo? ParseBackup(JsonElement item) - { - try - { - string? name = item.TryGetProperty("metadata", out JsonElement meta) - ? GetStringProp(meta, "name") - : null; - - if (name is null) - { - return null; - } - - string? method = null; - string? destPath = null; - - if (item.TryGetProperty("spec", out JsonElement spec)) - { - method = GetStringProp(spec, "method"); - - if (spec.TryGetProperty("target", out JsonElement target)) - { - destPath = GetStringProp(target, "objectStore"); - } - - // Newer CNPG versions use barmanObjectStore directly in spec. - - if (destPath is null && spec.TryGetProperty("barmanObjectStore", out JsonElement barman)) - { - destPath = GetStringProp(barman, "destinationPath"); - } - } - - string phase = "Unknown"; - string? backupSize = null; - string? walSize = null; - string? error = null; - DateTimeOffset? started = null; - DateTimeOffset? completed = null; - int? timelineId = null; - string? beginLsn = null; - string? endLsn = null; - - if (item.TryGetProperty("status", out JsonElement status)) - { - phase = GetStringProp(status, "phase") ?? "Unknown"; - error = GetStringProp(status, "error"); - - // CNPG uses startedAt in most versions. - - if (status.TryGetProperty("startedAt", out JsonElement startEl) && - DateTimeOffset.TryParse(startEl.GetString(), out DateTimeOffset startDt)) - { - started = startDt; - } - - // The completion timestamp can be "stoppedAt" or "completedAt". - - if (status.TryGetProperty("stoppedAt", out JsonElement stopEl) && - DateTimeOffset.TryParse(stopEl.GetString(), out DateTimeOffset stopDt)) - { - completed = stopDt; - } - else if (status.TryGetProperty("completedAt", out JsonElement compEl) && - DateTimeOffset.TryParse(compEl.GetString(), out DateTimeOffset compDt)) - { - completed = compDt; - } - - // Size fields vary: "backupSize" or "size", "walSize" or "walArchiveSize". - - backupSize = GetStringProp(status, "backupSize") - ?? GetStringProp(status, "size"); - walSize = GetStringProp(status, "walSize") - ?? GetStringProp(status, "walArchiveSize"); - - // Also check numeric size variants. - - if (backupSize is null && status.TryGetProperty("backupSizeBytes", out JsonElement sizeBytes) && - sizeBytes.ValueKind == JsonValueKind.Number) - { - backupSize = FormatBytesStatic(sizeBytes.GetInt64()); - } - - beginLsn = GetStringProp(status, "beginLSN") - ?? GetStringProp(status, "beginLsn"); - endLsn = GetStringProp(status, "endLSN") - ?? GetStringProp(status, "endLsn"); - timelineId = status.TryGetProperty("timelineID", out JsonElement tl) && tl.ValueKind == JsonValueKind.Number - ? tl.GetInt32() - : (status.TryGetProperty("timelineId", out JsonElement tl2) && tl2.ValueKind == JsonValueKind.Number - ? tl2.GetInt32() - : null); - - // Destination path might also be in status. - - if (destPath is null) - { - destPath = GetStringProp(status, "destinationPath"); - } - } - - return new CnpgBackupInfo( - Name: name, Phase: phase, Method: method, - StartedAt: started, CompletedAt: completed, - BackupSize: backupSize, WalSize: walSize, - DestinationPath: destPath, Error: error, - TimelineId: timelineId, BeginLsn: beginLsn, EndLsn: endLsn); - } - catch - { - return null; - } - } - - private static string FormatBytesStatic(long bytes) - { - string[] units = { "B", "KB", "MB", "GB", "TB" }; - double value = bytes; - int unitIndex = 0; - - while (value >= 1024 && unitIndex < units.Length - 1) - { - value /= 1024; - unitIndex++; - } - - return $"{value:F1} {units[unitIndex]}"; - } - - private static string? GetStringProp(JsonElement el, string name) - { - return el.TryGetProperty(name, out JsonElement prop) && prop.ValueKind == JsonValueKind.String - ? prop.GetString() - : null; - } - - private static int GetIntProp(JsonElement el, string name, int fallback = 0) - { - return el.TryGetProperty(name, out JsonElement prop) && prop.ValueKind == JsonValueKind.Number - ? prop.GetInt32() - : fallback; - } - - /// - /// Builds the full CNPG Cluster manifest with WAL archiving to MinIO. - /// This is the main resource that the CNPG operator watches and reconciles. - /// - private static object BuildClusterManifest(CnpgClusterSpec spec) - { - // Build the spec dictionary. Backup is optional — only included when - // S3/MinIO details are provided. This supports clusters without backups - // and clusters backed by external S3 providers (e.g. Cleura S3). - - Dictionary clusterSpec = new() - { - ["instances"] = spec.Instances, - ["imageName"] = $"ghcr.io/cloudnative-pg/postgresql:{spec.PostgresVersion}", - ["storage"] = new - { - size = spec.StorageSize, - storageClass = "default" - }, - ["postgresql"] = new - { - parameters = new Dictionary - { - ["max_parallel_workers"] = "4", - ["shared_buffers"] = "256MB", - ["effective_cache_size"] = "768MB" - } - }, - ["resources"] = new - { - requests = new { cpu = "500m", memory = "512Mi" }, - limits = new { cpu = "2000m", memory = "2Gi" } - }, - ["monitoring"] = new - { - enablePodMonitor = true - } - }; - - // When backup is configured, add Barman object store for WAL archiving - // and base backups. The endpoint URL is used as-is if it already has a - // scheme (https:// for external S3 providers), otherwise http:// is - // prepended (for internal MinIO). - - if (spec.HasBackupConfig) - { - string endpointUrl = spec.MinioEndpoint!.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - spec.MinioEndpoint!.StartsWith("https://", StringComparison.OrdinalIgnoreCase) - ? spec.MinioEndpoint - : $"http://{spec.MinioEndpoint}"; - - // Build the s3Credentials section. When an S3Region is provided - // (e.g. for Cleura or AWS), we include the region reference so - // barman-cloud uses the correct region for AWS Signature V4 signing. - // The region value is stored in the same credentials secret under - // the AWS_DEFAULT_REGION key. - - Dictionary s3Credentials = new() - { - ["accessKeyId"] = new { name = spec.MinioCredentialsSecret, key = "ACCESS_KEY_ID" }, - ["secretAccessKey"] = new { name = spec.MinioCredentialsSecret, key = "ACCESS_SECRET_KEY" } - }; - - if (!string.IsNullOrEmpty(spec.S3Region)) - { - s3Credentials["region"] = new { name = spec.MinioCredentialsSecret, key = "AWS_DEFAULT_REGION" }; - } - - // External S3 providers like Cleura (OpenStack Swift S3 layer) don't - // support newer boto3 features such as automatic CRC32 checksum - // headers (x-amz-checksum-crc32) added in boto3 1.35+. Swift rejects - // these with InvalidArgument on PutObject. We disable automatic - // checksum calculation for external providers via environment variables - // on the CNPG pods. - - bool useExternalS3 = !string.IsNullOrEmpty(spec.S3Region); - - if (useExternalS3) - { - clusterSpec["env"] = new[] - { - new { name = "AWS_REQUEST_CHECKSUM_CALCULATION", value = "when_required" }, - new { name = "AWS_RESPONSE_CHECKSUM_VALIDATION", value = "when_required" } - }; - } - - clusterSpec["backup"] = new - { - barmanObjectStore = new - { - destinationPath = $"s3://{spec.MinioBucket}/{spec.Name}/", - endpointURL = endpointUrl, - s3Credentials = s3Credentials, - wal = new - { - compression = "gzip", - maxParallel = 4 - }, - data = new - { - compression = "gzip" - } - }, - retentionPolicy = $"{spec.BackupRetentionDays}d" - }; - - // Enable WAL archiving in postgresql parameters. - - if (clusterSpec["postgresql"] is { } pgSpec) - { - clusterSpec["postgresql"] = new - { - parameters = new Dictionary - { - ["archive_mode"] = "on", - ["archive_timeout"] = "300s", - ["max_parallel_workers"] = "4", - ["shared_buffers"] = "256MB", - ["effective_cache_size"] = "768MB" - } - }; - } - } - - return new - { - apiVersion = $"{CnpgGroup}/{CnpgVersion}", - kind = "Cluster", - metadata = new - { - name = spec.Name, - @namespace = spec.Namespace, - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "cnpg-platform" - } - }, - spec = clusterSpec - }; - } - - /// - /// Builds a ScheduledBackup resource for periodic base backups to MinIO. - /// - private static object BuildScheduledBackupManifest(CnpgClusterSpec spec) - { - return new - { - apiVersion = $"{CnpgGroup}/{CnpgVersion}", - kind = "ScheduledBackup", - metadata = new - { - name = $"{spec.Name}-scheduled-backup", - @namespace = spec.Namespace, - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - spec = new - { - schedule = spec.BackupSchedule, - backupOwnerReference = "cluster", - cluster = new { name = spec.Name }, - method = "barmanObjectStore", - retentionPolicy = $"{spec.BackupRetentionDays}d" - } - }; - } - - private DiscoveredPostgresCluster? ParseDiscoveredCluster(JsonElement item) - { - try - { - JsonElement metadata = item.GetProperty("metadata"); - JsonElement spec = item.GetProperty("spec"); - - string name = metadata.GetProperty("name").GetString() ?? ""; - string ns = metadata.GetProperty("namespace").GetString() ?? ""; - - int instances = spec.TryGetProperty("instances", out JsonElement instEl) - ? instEl.GetInt32() - : 1; - - string? imageName = spec.TryGetProperty("imageName", out JsonElement imgEl) - ? imgEl.GetString() - : null; - - // Extract PostgreSQL version from multiple sources. The version can come from: - // 1. status.postgresqlVersion — the actual running version (most accurate) - // 2. spec.imageName tag — e.g., "ghcr.io/cloudnative-pg/postgresql:16.4" - // 3. spec.imageCatalogRef / spec.imageName version suffix - // 4. status.image tag as a fallback - - string version = "unknown"; - - // Prefer the status-reported version (what's actually running). - - if (item.TryGetProperty("status", out JsonElement versionStatus)) - { - if (versionStatus.TryGetProperty("postgresqlVersion", out JsonElement pgVersionEl) && - pgVersionEl.ValueKind == JsonValueKind.String) - { - version = pgVersionEl.GetString() ?? "unknown"; - } - else if (versionStatus.TryGetProperty("image", out JsonElement statusImgEl) && - statusImgEl.ValueKind == JsonValueKind.String) - { - string? statusImage = statusImgEl.GetString(); - - if (statusImage is not null) - { - int tagIdx = statusImage.LastIndexOf(':'); - - if (tagIdx > 0) - { - version = statusImage[(tagIdx + 1)..]; - } - } - } - } - - // Fall back to spec.imageName if status didn't have the version. - - if (version == "unknown" && imageName is not null) - { - int tagIndex = imageName.LastIndexOf(':'); - - if (tagIndex > 0) - { - version = imageName[(tagIndex + 1)..]; - } - } - - string storageSize = "unknown"; - - if (spec.TryGetProperty("storage", out JsonElement storageEl) && - storageEl.TryGetProperty("size", out JsonElement sizeEl)) - { - storageSize = sizeEl.GetString() ?? "unknown"; - } - - // Extract databases from multiple sources in the CR spec. - // CNPG stores database info in different locations depending on version - // and how the cluster was configured: - // - // 1. spec.bootstrap.initdb.database — the initial bootstrap database - // 2. spec.managed.databases — CNPG managed databases (v1.21+) - // 3. spec.bootstrap.initdb.databasesToCreate — older CNPG convention - // - // We merge all sources to build a complete list. The "postgres" database - // always exists but we don't include system databases here. - - List databases = new(); - - if (spec.TryGetProperty("bootstrap", out JsonElement bootstrap) && - bootstrap.TryGetProperty("initdb", out JsonElement initdb)) - { - // The primary bootstrap database. - - if (initdb.TryGetProperty("database", out JsonElement dbEl)) - { - string? dbName = dbEl.GetString(); - - if (dbName is not null && !databases.Contains(dbName, StringComparer.OrdinalIgnoreCase)) - { - databases.Add(dbName); - } - } - - // Some clusters use postInitSQL or postInitApplicationSQL to create - // additional databases — we can't parse arbitrary SQL, but initdb - // sometimes has a databasesToCreate array. - - if (initdb.TryGetProperty("databasesToCreate", out JsonElement dbsToCreate) && - dbsToCreate.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement dbItem in dbsToCreate.EnumerateArray()) - { - string? extraDb = dbItem.TryGetProperty("name", out JsonElement nameEl) - ? nameEl.GetString() - : dbItem.GetString(); - - if (extraDb is not null && !databases.Contains(extraDb, StringComparer.OrdinalIgnoreCase)) - { - databases.Add(extraDb); - } - } - } - } - - // CNPG 1.21+ declarative database management under spec.managed.databases. - - if (spec.TryGetProperty("managed", out JsonElement managed) && - managed.TryGetProperty("databases", out JsonElement managedDbs) && - managedDbs.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement dbDef in managedDbs.EnumerateArray()) - { - string? managedDbName = dbDef.TryGetProperty("name", out JsonElement mNameEl) - ? mNameEl.GetString() - : null; - - if (managedDbName is not null && !databases.Contains(managedDbName, StringComparer.OrdinalIgnoreCase)) - { - databases.Add(managedDbName); - } - } - } - - // Also check status for database info reported by the operator. - - if (item.TryGetProperty("status", out JsonElement statusEl)) - { - // CNPG reports managed databases in status.managedDatabases (newer versions). - - if (statusEl.TryGetProperty("managedDatabases", out JsonElement statusDbs) && - statusDbs.ValueKind == JsonValueKind.Array) - { - foreach (JsonElement sDbEl in statusDbs.EnumerateArray()) - { - string? sDbName = sDbEl.TryGetProperty("name", out JsonElement sNameEl) - ? sNameEl.GetString() - : sDbEl.GetString(); - - if (sDbName is not null && !databases.Contains(sDbName, StringComparer.OrdinalIgnoreCase)) - { - databases.Add(sDbName); - } - } - } - } - - // Extract MinIO backup configuration. - string? minioEndpoint = null; - string? minioBucket = null; - string? minioSecret = null; - bool walEnabled = false; - - if (spec.TryGetProperty("backup", out JsonElement backupEl) && - backupEl.TryGetProperty("barmanObjectStore", out JsonElement barman)) - { - walEnabled = true; - - if (barman.TryGetProperty("endpointURL", out JsonElement endpointEl)) - { - string? endpoint = endpointEl.GetString(); - - if (endpoint is not null) - { - // Preserve the full endpoint URL including scheme. External - // S3 providers like Cleura use https:// and stripping it - // would cause BuildClusterManifest to incorrectly prepend http://. - minioEndpoint = endpoint; - } - } - - if (barman.TryGetProperty("destinationPath", out JsonElement destEl)) - { - string? destPath = destEl.GetString(); - - if (destPath is not null && destPath.StartsWith("s3://")) - { - // Extract bucket name from s3://bucket/path/ - string pathWithoutScheme = destPath[5..]; - int slashIndex = pathWithoutScheme.IndexOf('/'); - minioBucket = slashIndex > 0 ? pathWithoutScheme[..slashIndex] : pathWithoutScheme; - } - } - - if (barman.TryGetProperty("s3Credentials", out JsonElement credsEl) && - credsEl.TryGetProperty("accessKeyId", out JsonElement keyEl) && - keyEl.TryGetProperty("name", out JsonElement secretNameEl)) - { - minioSecret = secretNameEl.GetString(); - } - } - - return new DiscoveredPostgresCluster( - name, ns, version, instances, storageSize, databases, - minioEndpoint, minioBucket, minioSecret, walEnabled); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to parse CNPG Cluster resource"); - return null; - } - } - - private static Kubernetes BuildClient(string kubeConfig, string contextName) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(kubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, contextName); - return new Kubernetes(config); - } - - /// - /// Ensures the target namespace exists on the cluster. If it doesn't exist, - /// we create it with the managed-by label so EntKube can track it. If it - /// already exists, this is a no-op. - /// - private async Task EnsureNamespaceAsync(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - logger.LogInformation("Created namespace '{Namespace}' for CNPG cluster", namespaceName); - } - } - - /// - /// Ensures the backup credentials secret exists in the target namespace. - /// The CNPG installer creates this secret in cnpg-system, but the CNPG operator - /// resolves secrets from the Cluster CR's own namespace. If the secret is missing - /// in the target namespace, we copy it from cnpg-system so barman-cloud-backup - /// can authenticate with the S3/MinIO endpoint. - /// - private async Task EnsureBackupSecretInNamespaceAsync( - Kubernetes client, string secretName, string targetNamespace, CancellationToken ct, - string? accessKey = null, string? secretKey = null, string? region = null, string? endpoint = null, string? bucket = null) - { - // First, check if the secret already exists in the target namespace. - // If so, we're done — the cluster can use it. - - try - { - await client.CoreV1.ReadNamespacedSecretAsync(secretName, targetNamespace, cancellationToken: ct); - logger.LogInformation( - "Backup credentials secret '{SecretName}' already exists in '{Namespace}'", - secretName, targetNamespace); - return; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Not in target namespace — continue to copy or create. - } - - // Try to read the source secret from cnpg-system where the installer created it. - - V1Secret? sourceSecret = null; - - try - { - sourceSecret = await client.CoreV1.ReadNamespacedSecretAsync( - secretName, "cnpg-system", cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogWarning( - "Backup credentials secret '{SecretName}' not found in cnpg-system", - secretName); - } - - // Build the secret data — either from the cnpg-system copy or from the raw - // credentials passed in. If neither source is available, backup will fail. - - Dictionary? secretData = null; - - if (sourceSecret?.Data is not null) - { - secretData = sourceSecret.Data.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - else if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey)) - { - // Raw credentials provided — create the secret directly. This handles the - // case where the installer never created the secret in cnpg-system, or it - // was deleted. The credentials come from the Clusters service via the BFF. - - secretData = new Dictionary - { - ["ACCESS_KEY_ID"] = Encoding.UTF8.GetBytes(accessKey), - ["ACCESS_SECRET_KEY"] = Encoding.UTF8.GetBytes(secretKey) - }; - - if (!string.IsNullOrWhiteSpace(region)) - { - secretData["AWS_DEFAULT_REGION"] = Encoding.UTF8.GetBytes(region); - } - - if (!string.IsNullOrWhiteSpace(endpoint)) - { - secretData["ENDPOINT_URL"] = Encoding.UTF8.GetBytes(endpoint); - } - - if (!string.IsNullOrWhiteSpace(bucket)) - { - secretData["BUCKET_NAME"] = Encoding.UTF8.GetBytes(bucket); - } - - logger.LogInformation( - "Creating backup credentials secret '{SecretName}' in '{Namespace}' from raw credentials", - secretName, targetNamespace); - } - else - { - logger.LogError( - "Cannot create backup credentials secret '{SecretName}' in '{Namespace}' — " + - "not found in cnpg-system and no raw credentials provided. WAL archiving will fail.", - secretName, targetNamespace); - return; - } - - // Create the secret in the target namespace. - - V1Secret targetSecret = new() - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "cnpg-backup" - } - }, - Data = secretData - }; - - try - { - await client.CoreV1.CreateNamespacedSecretAsync( - targetSecret, targetNamespace, cancellationToken: ct); - - logger.LogInformation( - "Created backup credentials secret '{SecretName}' in '{Namespace}'", - secretName, targetNamespace); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - // Secret was created between our check and now — replace it. - - await client.CoreV1.ReplaceNamespacedSecretAsync( - targetSecret, secretName, targetNamespace, cancellationToken: ct); - - logger.LogInformation( - "Replaced backup credentials secret '{SecretName}' in '{Namespace}'", - secretName, targetNamespace); - } - } - - public async Task> ListLiveDatabasesAsync( - string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default) - { - // Query the running PostgreSQL primary pod to list all user databases. - // We use kubectl exec directly into the CNPG primary pod to run psql - // against the local socket — this is far more reliable than creating - // a Job because: - // - No image pull needed (the pod is already running) - // - No network policies to worry about (local connection) - // - No superuser secret lookup (CNPG primary runs as postgres user) - // - No cleanup needed - - Kubernetes client = BuildClient(kubeConfig, contextName); - List databases = new(); - - try - { - // Find the primary pod. CNPG labels the primary with role=primary. - - string? primaryPodName = await FindPrimaryPodAsync(client, clusterName, ns, ct); - - if (primaryPodName is null) - { - logger.LogWarning("No primary pod found for CNPG cluster '{Cluster}'", clusterName); - return databases; - } - - // Execute psql inside the primary pod to list databases. - // CNPG pods have psql available and the postgres user can connect - // without a password via the local Unix socket. - - string[] command = new[] - { - "psql", "-U", "postgres", "-t", "-A", "-c", - "SELECT datname FROM pg_database WHERE datistemplate = false AND datname NOT IN ('postgres') ORDER BY datname;" - }; - - string output = await ExecInPodAsync(client, primaryPodName, ns, "postgres", command, ct); - - // Parse the output — each line is a database name. - - foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith("ERROR") && !line.StartsWith("psql:")) - { - databases.Add(line); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list live databases on cluster '{Cluster}'", clusterName); - } - - return databases; - } - - /// - /// Finds the primary pod for a CNPG cluster by looking for pods with the - /// CNPG role label set to "primary". - /// - private static async Task FindPrimaryPodAsync( - Kubernetes client, string clusterName, string ns, CancellationToken ct) - { - // CNPG labels pods with cnpg.io/cluster and role. - // Try multiple label selectors since the label names changed across versions. - - string[] labelSelectors = new[] - { - $"cnpg.io/cluster={clusterName},role=primary", - $"postgresql={clusterName},role=primary", - $"cnpg.io/cluster={clusterName},cnpg.io/instanceRole=primary" - }; - - foreach (string selector in labelSelectors) - { - try - { - V1PodList pods = await client.CoreV1.ListNamespacedPodAsync( - ns, labelSelector: selector, cancellationToken: ct); - - if (pods.Items.Count > 0) - { - return pods.Items[0].Metadata.Name; - } - } - catch - { - // Try next selector. - } - } - - // Fallback: look for the pod named {clusterName}-1 which is typically the - // first instance and often the primary. - - try - { - V1PodList allPods = await client.CoreV1.ListNamespacedPodAsync( - ns, labelSelector: $"cnpg.io/cluster={clusterName}", cancellationToken: ct); - - if (allPods.Items.Count > 0) - { - // Prefer pods that are ready. - - V1Pod? readyPod = allPods.Items.FirstOrDefault(p => - p.Status?.ContainerStatuses?.Any(c => c.Ready) == true); - - return readyPod?.Metadata.Name ?? allPods.Items[0].Metadata.Name; - } - } - catch - { - // All selectors failed. - } - - return null; - } - - /// - /// Executes a command inside a running pod and returns stdout as a string. - /// - private static async Task ExecInPodAsync( - Kubernetes client, string podName, string ns, string container, - string[] command, CancellationToken ct) - { - // Use the K8s exec API to run a command inside a running pod. - // This captures stdout and stderr into separate streams. - - WebSocket? webSocket = await client.WebSocketNamespacedPodExecAsync( - name: podName, - @namespace: ns, - command: command, - container: container, - stderr: true, - stdin: false, - stdout: true, - tty: false, - cancellationToken: ct); - - using StreamDemuxer demuxer = new(webSocket); - demuxer.Start(); - - using Stream stdout = demuxer.GetStream(1, null); - using StreamReader reader = new(stdout); - - // Read with a timeout to avoid hanging if the command stalls. - - Task readTask = reader.ReadToEndAsync(ct); - Task completed = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(15), ct)); - - if (completed == readTask) - { - return await readTask; - } - - return string.Empty; - } - - public async Task RestoreBackupAsync( - string kubeConfig, string contextName, RestoreFromBackupSpec spec, CancellationToken ct = default) - { - // Create a new CNPG Cluster that bootstraps from a backup recovery source. - // CNPG uses the recovery bootstrap method to restore from the object store - // data (base backup + WAL segments). If a targetTime is specified, CNPG - // performs point-in-time recovery (PITR) to that exact moment. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - // Ensure the backup credentials secret exists in the target namespace. - // Same issue as CreateClusterAsync — the secret lives in cnpg-system but - // the restored cluster needs it in its own namespace. - - await EnsureBackupSecretInNamespaceAsync( - client, spec.MinioCredentialsSecret, spec.Namespace, ct); - - // Resolve the endpoint URL — use as-is if it already has a scheme - // (https:// for Cleura S3), otherwise prepend http:// (for internal MinIO). - - string endpointUrl = spec.MinioEndpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - spec.MinioEndpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase) - ? spec.MinioEndpoint - : $"http://{spec.MinioEndpoint}"; - - // Build s3Credentials. When an S3Region is provided (e.g. for Cleura), - // include the region reference for AWS Signature V4 signing. - - Dictionary s3Credentials = new() - { - ["accessKeyId"] = new { name = spec.MinioCredentialsSecret, key = "ACCESS_KEY_ID" }, - ["secretAccessKey"] = new { name = spec.MinioCredentialsSecret, key = "ACCESS_SECRET_KEY" } - }; - - if (!string.IsNullOrEmpty(spec.S3Region)) - { - s3Credentials["region"] = new { name = spec.MinioCredentialsSecret, key = "AWS_DEFAULT_REGION" }; - } - - // External S3 providers like Cleura (Swift S3 layer) don't support - // automatic CRC32 checksum headers from boto3 1.35+. Disable them. - - bool useExternalS3 = !string.IsNullOrEmpty(spec.S3Region); - - // Build the bootstrap section with recovery mode. - - Dictionary bootstrap = new() - { - ["recovery"] = new Dictionary - { - ["source"] = spec.SourceClusterName - } - }; - - // If a target time is specified, set the PITR target. - - if (spec.TargetTime.HasValue) - { - ((Dictionary)bootstrap["recovery"])["recoveryTarget"] = new - { - targetTime = spec.TargetTime.Value.ToString("yyyy-MM-dd HH:mm:ss.ffffffzzz") - }; - } - - // If a specific backup name is provided, reference it. - - if (spec.BackupName is not null) - { - ((Dictionary)bootstrap["recovery"])["backup"] = new - { - name = spec.BackupName - }; - } - - Dictionary restoreSpec = new() - { - ["instances"] = spec.Instances, - ["imageName"] = $"ghcr.io/cloudnative-pg/postgresql:{spec.PostgresVersion}", - ["storage"] = new - { - size = spec.StorageSize, - storageClass = "default" - }, - ["bootstrap"] = bootstrap, - ["externalClusters"] = new object[] - { - new - { - name = spec.SourceClusterName, - barmanObjectStore = new - { - destinationPath = $"s3://{spec.MinioBucket}/{spec.SourceClusterName}/", - endpointURL = endpointUrl, - s3Credentials = s3Credentials, - wal = new { maxParallel = 4 } - } - } - }, - ["backup"] = new - { - barmanObjectStore = new - { - destinationPath = $"s3://{spec.MinioBucket}/{spec.RestoredClusterName}/", - endpointURL = endpointUrl, - s3Credentials = s3Credentials, - wal = new { compression = "gzip", maxParallel = 4 }, - data = new { compression = "gzip" } - }, - retentionPolicy = "7d" - } - }; - - // Disable boto3 automatic CRC32 checksum headers for external S3 - // providers (e.g. Cleura Swift) that reject them with InvalidArgument. - - if (useExternalS3) - { - restoreSpec["env"] = new[] - { - new { name = "AWS_REQUEST_CHECKSUM_CALCULATION", value = "when_required" }, - new { name = "AWS_RESPONSE_CHECKSUM_VALIDATION", value = "when_required" } - }; - } - - object clusterManifest = new - { - apiVersion = $"{CnpgGroup}/{CnpgVersion}", - kind = "Cluster", - metadata = new - { - name = spec.RestoredClusterName, - @namespace = spec.Namespace, - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/part-of"] = "cnpg-platform", - ["entkube.io/restored-from"] = spec.SourceClusterName - } - }, - spec = restoreSpec - }; - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: clusterManifest, - group: CnpgGroup, version: CnpgVersion, - namespaceParameter: spec.Namespace, plural: ClusterPlural, - cancellationToken: ct); - - logger.LogInformation( - "Created restored cluster '{Restored}' from backup of '{Source}' in namespace '{Namespace}'", - spec.RestoredClusterName, spec.SourceClusterName, spec.Namespace); - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/KubernetesMongoClusterClient.cs b/src/EntKube.Provisioning/Infrastructure/KubernetesMongoClusterClient.cs deleted file mode 100644 index 2945533..0000000 --- a/src/EntKube.Provisioning/Infrastructure/KubernetesMongoClusterClient.cs +++ /dev/null @@ -1,1671 +0,0 @@ -using System.Text; -using System.Text.Json; -using EntKube.Provisioning.Features.MongoClusters; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// Implementation of IMongoClusterClient that talks to the Kubernetes API using -/// the official .NET KubernetesClient. Manages MongoDB Community Operator resources: -/// -/// - mongodbcommunity.mongodb.com/v1 MongoDBCommunity — the main replica set resource -/// - batch/v1 CronJob — scheduled mongodump backups to MinIO -/// - batch/v1 Job — on-demand backups and restore operations -/// -/// The MongoDB Community Operator watches MongoDBCommunity CRs and reconciles them -/// into running MongoDB replica sets with SCRAM authentication, persistent storage, -/// and automated member management. -/// -public class KubernetesMongoClusterClient : IMongoClusterClient -{ - private readonly ILogger logger; - - private const string MongoGroup = "mongodbcommunity.mongodb.com"; - private const string MongoVersion = "v1"; - private const string MongoPlural = "mongodbcommunity"; - - public KubernetesMongoClusterClient(ILogger logger) - { - this.logger = logger; - } - - public async Task> DiscoverClustersAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // Connect to the K8s cluster and list all MongoDBCommunity CRs across all namespaces. - - Kubernetes client = BuildClient(kubeConfig, contextName); - List discovered = new(); - - try - { - object result = await client.CustomObjects.ListClusterCustomObjectAsync( - group: MongoGroup, - version: MongoVersion, - plural: MongoPlural, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (!json.TryGetProperty("items", out JsonElement items)) - { - return discovered; - } - - foreach (JsonElement item in items.EnumerateArray()) - { - DiscoveredMongoCluster? cluster = ParseDiscoveredCluster(item); - - if (cluster is not null) - { - discovered.Add(cluster); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to discover MongoDBCommunity clusters"); - } - - return discovered; - } - - public async Task CreateClusterAsync( - string kubeConfig, string contextName, MongoClusterSpec spec, CancellationToken ct = default) - { - // Build the MongoDBCommunity manifest and create it on K8s. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - await EnsureNamespaceAsync(client, spec.Namespace, ct); - - // When backup is configured, ensure the credentials secret exists in the - // target namespace. The backup CronJob references it via envFrom — if the - // secret is missing, pods enter CreateContainerConfigError. - - if (spec.HasBackupConfig) - { - await EnsureBackupSecretInNamespaceAsync( - client, spec.MinioCredentialsSecret!, spec.Namespace, ct, - accessKey: spec.S3AccessKey, - secretKey: spec.S3SecretKey, - region: spec.S3Region, - endpoint: spec.MinioEndpoint, - bucket: spec.MinioBucket); - } - - // The MongoDB Community Operator requires the user password secret to - // exist before the MongoDBCommunity CR is created. The CR's - // passwordSecretRef points to this secret, and the operator will fail - // with "user password secret not found" if it's missing. - - await EnsureAdminPasswordSecretAsync(client, spec.Name, spec.Namespace, ct); - - // The operator's StatefulSet references a service account named - // "{name}-database". If it doesn't exist, the pods are forbidden from - // being created. Ensure it exists before applying the CR. - - await EnsureServiceAccountAsync(client, $"{spec.Name}-database", spec.Namespace, ct); - - // The MongoDB agent sidecar (readiness probe) needs to read secrets - // in the namespace (e.g. the automation-config secret). Without RBAC, - // the pod fails readiness with "secrets is forbidden". - - await EnsureServiceAccountRbacAsync(client, spec.Name, spec.Namespace, ct); - - object clusterManifest = BuildMongoClusterManifest(spec); - - // Create-or-replace the MongoDBCommunity CR so retries don't fail - // with Conflict (409) if the CR was already created. - - try - { - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: clusterManifest, - group: MongoGroup, - version: MongoVersion, - namespaceParameter: spec.Namespace, - plural: MongoPlural, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - // CR already exists — patch it with the desired state. - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(clusterManifest, V1Patch.PatchType.MergePatch), - group: MongoGroup, - version: MongoVersion, - namespaceParameter: spec.Namespace, - plural: MongoPlural, - name: spec.Name, - cancellationToken: ct); - } - - // Create the backup CronJob only if S3/MinIO backup is configured. - // Use replace-or-create so retries don't fail with Conflict (409). - - if (spec.HasBackupConfig) - { - V1CronJob backupCronJob = BuildBackupCronJob(spec); - - try - { - await client.BatchV1.CreateNamespacedCronJobAsync( - backupCronJob, spec.Namespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - // CronJob already exists (previous attempt or re-create). Replace it. - - await client.BatchV1.ReplaceNamespacedCronJobAsync( - backupCronJob, backupCronJob.Metadata.Name, spec.Namespace, cancellationToken: ct); - } - } - - logger.LogInformation( - "Created MongoDBCommunity '{Name}' in namespace '{Namespace}' with {Members} members", - spec.Name, spec.Namespace, spec.Members); - } - - public async Task UpdateClusterAsync( - string kubeConfig, string contextName, MongoClusterSpec spec, CancellationToken ct = default) - { - // Patch the existing MongoDBCommunity resource with updated values. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - object patchBody = new - { - spec = new - { - members = spec.Members, - version = spec.MongoVersion, - statefulSet = new - { - spec = new - { - volumeClaimTemplates = new[] - { - new - { - spec = new - { - resources = new - { - requests = new Dictionary - { - ["storage"] = spec.StorageSize - } - } - } - } - } - } - } - } - }; - - string patchJson = JsonSerializer.Serialize(patchBody); - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: MongoGroup, - version: MongoVersion, - namespaceParameter: spec.Namespace, - plural: MongoPlural, - name: spec.Name, - cancellationToken: ct); - - logger.LogInformation("Updated MongoDBCommunity '{Name}': members={Members}, storage={Storage}", - spec.Name, spec.Members, spec.StorageSize); - } - - public async Task UpgradeClusterAsync( - string kubeConfig, string contextName, string name, string ns, - string targetVersion, CancellationToken ct = default) - { - // Upgrade means changing the version field on the MongoDBCommunity resource. - // The operator handles the rolling update: secondaries first, then primary - // stepdown and upgrade. It also manages featureCompatibilityVersion. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - object patchBody = new - { - spec = new - { - version = targetVersion - } - }; - - string patchJson = JsonSerializer.Serialize(patchBody); - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: patch, - group: MongoGroup, - version: MongoVersion, - namespaceParameter: ns, - plural: MongoPlural, - name: name, - cancellationToken: ct); - - logger.LogInformation("Initiated upgrade of MongoDBCommunity '{Name}' to MongoDB {Version}", - name, targetVersion); - } - - public async Task CreateDatabaseAsync( - string kubeConfig, string contextName, string clusterName, string ns, - string databaseName, string ownerRole, CancellationToken ct = default) - { - // Creating a database and user on a running MongoDB cluster. We create a - // Kubernetes Job that runs mongosh against the primary to create the database - // and a user with readWrite role. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - // The MongoDB Community Operator creates a service named -svc. - - string connectionString = $"mongodb://{clusterName}-svc.{ns}.svc.cluster.local:27017/?replicaSet={clusterName}"; - - // The admin credentials are stored in a secret created by the operator. - - string secretName = $"{clusterName}-admin-my-user"; - - V1Job job = new() - { - Metadata = new V1ObjectMeta - { - Name = $"create-db-{clusterName}-{databaseName}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "create-database" - } - }, - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 300, - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "create-db", - Image = "mongo:7.0", - Command = new List { "mongosh" }, - Args = new List - { - connectionString, - "--eval", - $"db = db.getSiblingDB('{EscapeMongoString(databaseName)}'); " + - $"db.createUser({{ user: '{EscapeMongoString(ownerRole)}', " + - $"pwd: UUID().toString(), " + - $"roles: [{{ role: 'readWrite', db: '{EscapeMongoString(databaseName)}' }}] }}); " + - $"db.createCollection('_init');" - } - } - } - } - } - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - logger.LogInformation("Created database '{Database}' on MongoDBCommunity '{Cluster}'", - databaseName, clusterName); - } - - public async Task GetClusterStatusAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - object result = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: MongoGroup, - version: MongoVersion, - namespaceParameter: ns, - plural: MongoPlural, - name: name, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (!json.TryGetProperty("status", out JsonElement status)) - { - return null; - } - - string phase = status.TryGetProperty("phase", out JsonElement phaseEl) - ? phaseEl.GetString() ?? "Unknown" - : "Unknown"; - - int readyMembers = status.TryGetProperty("currentStatefulSetReplicas", out JsonElement readyEl) - ? readyEl.GetInt32() - : 0; - - int totalMembers = 0; - - if (json.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("members", out JsonElement membersEl)) - { - totalMembers = membersEl.GetInt32(); - } - - return new MongoCrdStatus(phase, readyMembers, totalMembers, null, null); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to get status for MongoDBCommunity '{Name}' in '{Namespace}'", name, ns); - return null; - } - } - - public async Task ConfigureScheduledBackupAsync( - string kubeConfig, string contextName, string clusterName, string ns, - string schedule, int retentionDays, CancellationToken ct = default) - { - // Update or create the backup CronJob for this cluster. - - Kubernetes client = BuildClient(kubeConfig, contextName); - string cronJobName = $"{clusterName}-backup"; - - try - { - // Try to patch the existing CronJob. - - object patchBody = new - { - spec = new - { - schedule, - jobTemplate = new - { - spec = new - { - template = new - { - spec = new - { - containers = new[] - { - new - { - name = "mongodump", - env = new[] - { - new { name = "RETENTION_DAYS", value = retentionDays.ToString() } - } - } - } - } - } - } - } - } - }; - - string patchJson = JsonSerializer.Serialize(patchBody); - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.BatchV1.PatchNamespacedCronJobAsync( - patch, cronJobName, ns, cancellationToken: ct); - - logger.LogInformation("Updated backup CronJob '{Name}' schedule={Schedule}, retention={Retention}d", - cronJobName, schedule, retentionDays); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // CronJob doesn't exist yet — create it. - - MongoClusterSpec spec = new( - Name: clusterName, - Namespace: ns, - MongoVersion: "7.0", - Members: 1, - StorageSize: "1Gi", - MinioEndpoint: "minio.minio-system.svc:9000", - MinioBucket: "mongo-backups", - MinioCredentialsSecret: "minio-mongo-credentials", - BackupSchedule: schedule, - BackupRetentionDays: retentionDays); - - V1CronJob cronJob = BuildBackupCronJob(spec); - - await client.BatchV1.CreateNamespacedCronJobAsync( - cronJob, ns, cancellationToken: ct); - - logger.LogInformation("Created backup CronJob '{Name}'", cronJobName); - } - } - - public async Task> GetAvailableVersionsAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // Returns MongoDB versions supported by the Community Operator, ordered - // newest-first. Only includes currently supported (non-EOL) major lines. - // 5.0 and 6.0 are EOL — included for existing clusters but not recommended. - - await Task.CompletedTask; - - return new List - { - "8.0.9", - "8.0.8", - "8.0.6", - "8.0.4", - "8.0.3", - "8.0.1", - "8.0.0", - "7.0.20", - "7.0.18", - "7.0.15", - "7.0.14", - "7.0.12", - "6.0.19", - "6.0.17", - "6.0.16", - "5.0.30", - "5.0.28" - }; - } - - public async Task GetClusterHealthAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - // Read the MongoDBCommunity CR for status. - - object result = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: MongoGroup, - version: MongoVersion, - namespaceParameter: ns, - plural: MongoPlural, - name: name, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - JsonElement status = json.GetProperty("status"); - - string phase = status.TryGetProperty("phase", out JsonElement phaseEl) - ? phaseEl.GetString() ?? "Unknown" - : "Unknown"; - - int readyMembers = status.TryGetProperty("currentStatefulSetReplicas", out JsonElement readyEl) - ? readyEl.GetInt32() - : 0; - - int totalMembers = 0; - - if (json.TryGetProperty("spec", out JsonElement spec) && - spec.TryGetProperty("members", out JsonElement membersEl)) - { - totalMembers = membersEl.GetInt32(); - } - - // List the StatefulSet pods for per-member details. - - V1PodList pods = await client.CoreV1.ListNamespacedPodAsync( - namespaceParameter: ns, - labelSelector: $"app={name}-svc", - cancellationToken: ct); - - List memberDetails = new(); - - foreach (V1Pod pod in pods.Items) - { - bool ready = pod.Status?.ContainerStatuses?.All(c => c.Ready) ?? false; - string podPhase = pod.Status?.Phase ?? "Unknown"; - string role = ready && memberDetails.Count == 0 ? "Primary" : "Secondary"; - - memberDetails.Add(new MongoMemberInfo( - Name: pod.Metadata.Name, - Role: role, - State: podPhase, - Ready: ready, - ReplicationLagSeconds: null, - PodPhase: podPhase)); - } - - return new MongoClusterHealth( - Phase: phase, - ReadyMembers: readyMembers, - TotalMembers: totalMembers, - CurrentPrimary: memberDetails.FirstOrDefault(m => m.Role == "Primary")?.Name, - OplogWindowHours: 0, - LastBackup: null, - MemberDetails: memberDetails, - Replication: new MongoReplicationInfo( - ReplicationActive: readyMembers > 1, - SyncMembers: Math.Max(0, readyMembers - 1), - MaxLagSeconds: 0)); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to get health for MongoDBCommunity '{Name}'", name); - return null; - } - } - - public async Task> ListBackupsAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // List backup Jobs created by the CronJob or manually triggered. - - Kubernetes client = BuildClient(kubeConfig, contextName); - List backups = new(); - - try - { - V1JobList jobs = await client.BatchV1.ListNamespacedJobAsync( - namespaceParameter: ns, - labelSelector: $"entkube.io/cluster={name},entkube.io/operation=backup", - cancellationToken: ct); - - foreach (V1Job job in jobs.Items.OrderByDescending(j => j.Status?.StartTime)) - { - string jobStatus = job.Status?.Succeeded > 0 ? "Completed" - : job.Status?.Failed > 0 ? "Failed" - : "Running"; - - backups.Add(new MongoBackupInfo( - Name: job.Metadata.Name, - Status: jobStatus, - StartedAt: job.Status?.StartTime, - CompletedAt: job.Status?.CompletionTime, - BackupSize: null, - DestinationPath: $"s3://mongo-backups/{name}/", - Error: jobStatus == "Failed" ? "Job failed" : null)); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list backups for '{Name}'", name); - } - - return backups; - } - - public async Task TriggerBackupAsync( - string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default) - { - // Create an immediate mongodump Job. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmss"); - - V1Job job = new() - { - Metadata = new V1ObjectMeta - { - Name = $"{clusterName}-manual-{timestamp}", - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "backup", - ["entkube.io/cluster"] = clusterName - } - }, - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 3600, - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "mongodump", - Image = "mongo:7.0", - Command = new List { "/bin/sh", "-c" }, - Args = new List - { - $"mongodump --uri='mongodb://{clusterName}-svc.{ns}.svc.cluster.local:27017/?replicaSet={clusterName}' " + - "--archive=/tmp/backup.gz --gzip && " + - "echo 'Backup completed, uploading to MinIO...'" - } - } - } - } - } - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - logger.LogInformation("Triggered manual backup for MongoDBCommunity '{Cluster}'", clusterName); - } - - public async Task DeleteDatabaseAsync( - string kubeConfig, string contextName, string clusterName, string ns, - string databaseName, CancellationToken ct = default) - { - // Drop a database by running mongosh against the primary via a Job. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string connectionString = $"mongodb://{clusterName}-svc.{ns}.svc.cluster.local:27017/?replicaSet={clusterName}"; - - V1Job job = new() - { - Metadata = new V1ObjectMeta - { - Name = $"drop-db-{clusterName}-{databaseName}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "drop-database" - } - }, - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 300, - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "drop-db", - Image = "mongo:7.0", - Command = new List { "mongosh" }, - Args = new List - { - connectionString, - "--eval", - $"db.getSiblingDB('{EscapeMongoString(databaseName)}').dropDatabase();" - } - } - } - } - } - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - logger.LogInformation("Dropped database '{Database}' from MongoDBCommunity '{Cluster}'", - databaseName, clusterName); - } - - public async Task RestartClusterAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Rolling restart by annotating the StatefulSet with a restart timestamp. - // The operator will restart pods one by one. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string statefulSetName = $"{name}"; - - object patchBody = new - { - spec = new - { - template = new - { - metadata = new - { - annotations = new Dictionary - { - ["entkube.io/restartedAt"] = DateTimeOffset.UtcNow.ToString("o") - } - } - } - } - }; - - string patchJson = JsonSerializer.Serialize(patchBody); - V1Patch patch = new(patchJson, V1Patch.PatchType.MergePatch); - - await client.AppsV1.PatchNamespacedStatefulSetAsync( - patch, statefulSetName, ns, cancellationToken: ct); - - logger.LogInformation("Initiated rolling restart of MongoDBCommunity '{Name}'", name); - } - - public async Task DeleteClusterAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Delete the MongoDBCommunity CR. The operator cleans up the StatefulSet, - // pods, services, and other owned resources. - // If the resource is already gone from the cluster (404), we treat that as success. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: MongoGroup, - version: MongoVersion, - namespaceParameter: ns, - plural: MongoPlural, - name: name, - cancellationToken: ct); - - logger.LogInformation("Deleted MongoDBCommunity '{Name}' from namespace '{Namespace}'", name, ns); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogInformation("MongoDBCommunity '{Name}' already removed from namespace '{Namespace}'", name, ns); - } - } - - public async Task StepDownPrimaryAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Force the primary to step down by running rs.stepDown() via a Job. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - string connectionString = $"mongodb://{name}-svc.{ns}.svc.cluster.local:27017/?replicaSet={name}"; - - V1Job job = new() - { - Metadata = new V1ObjectMeta - { - Name = $"stepdown-{name}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", - NamespaceProperty = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "stepdown" - } - }, - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 300, - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "Never", - Containers = new List - { - new() - { - Name = "stepdown", - Image = "mongo:7.0", - Command = new List { "mongosh" }, - Args = new List - { - connectionString, - "--eval", - "rs.stepDown();" - } - } - } - } - } - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(job, ns, cancellationToken: ct); - - logger.LogInformation("Initiated primary stepdown for MongoDBCommunity '{Name}'", name); - } - - public async Task> ListLiveDatabasesAsync( - string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default) - { - // Query the running MongoDB primary to list all user databases. - // We run mongosh via exec into the first pod of the StatefulSet. - - Kubernetes client = BuildClient(kubeConfig, contextName); - List databases = new(); - - try - { - // Find pods for this MongoDB cluster. - - V1PodList pods = await client.CoreV1.ListNamespacedPodAsync( - namespaceParameter: ns, - labelSelector: $"app={clusterName}-svc", - cancellationToken: ct); - - string? primaryPod = pods.Items.FirstOrDefault()?.Metadata.Name; - - if (primaryPod is null) - { - logger.LogWarning("No pods found for MongoDBCommunity '{Cluster}'", clusterName); - return databases; - } - - // Execute mongosh to list databases, excluding system databases. - - string[] command = new[] - { - "mongosh", - "--quiet", - "--eval", - "db.adminCommand({listDatabases: 1}).databases.map(d => d.name).filter(n => !['admin','local','config'].includes(n)).join('\\n')" - }; - - string output = await ExecInPodAsync(client, primaryPod, ns, "mongodb-agent", command, ct); - - foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (!string.IsNullOrWhiteSpace(line) && line != "admin" && line != "local" && line != "config") - { - databases.Add(line); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to list live databases for MongoDBCommunity '{Cluster}'", clusterName); - } - - return databases; - } - - public async Task RestoreBackupAsync( - string kubeConfig, string contextName, RestoreFromMongoBackupSpec spec, CancellationToken ct = default) - { - // Restore means: - // 1. Create a new MongoDBCommunity cluster - // 2. Wait for it to be ready (the caller/reconciler handles this) - // 3. Run a mongorestore Job to load the backup data - // - // For now, we create the cluster and the restore Job. The Job will wait - // for the replica set to be available before starting the restore. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - await EnsureNamespaceAsync(client, spec.Namespace, ct); - - // Create the new MongoDBCommunity cluster. - - MongoClusterSpec clusterSpec = new( - Name: spec.RestoredClusterName, - Namespace: spec.Namespace, - MongoVersion: spec.MongoVersion, - Members: spec.Members, - StorageSize: spec.StorageSize, - MinioEndpoint: spec.MinioEndpoint, - MinioBucket: spec.MinioBucket, - MinioCredentialsSecret: spec.MinioCredentialsSecret, - BackupSchedule: "0 0 * * *", - BackupRetentionDays: 7); - - object manifest = BuildMongoClusterManifest(clusterSpec); - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: manifest, - group: MongoGroup, - version: MongoVersion, - namespaceParameter: spec.Namespace, - plural: MongoPlural, - cancellationToken: ct); - - // Create a restore Job that runs mongorestore from MinIO. - - string backupPath = spec.BackupName is not null - ? $"s3://{spec.MinioBucket}/{spec.SourceClusterName}/{spec.BackupName}" - : $"s3://{spec.MinioBucket}/{spec.SourceClusterName}/latest"; - - string connectionString = $"mongodb://{spec.RestoredClusterName}-svc.{spec.Namespace}.svc.cluster.local:27017/?replicaSet={spec.RestoredClusterName}"; - - V1Job restoreJob = new() - { - Metadata = new V1ObjectMeta - { - Name = $"restore-{spec.RestoredClusterName}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", - NamespaceProperty = spec.Namespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "restore", - ["entkube.io/cluster"] = spec.RestoredClusterName - } - }, - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 3600, - BackoffLimit = 5, - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - RestartPolicy = "OnFailure", - InitContainers = new List - { - new() - { - Name = "wait-for-mongo", - Image = "mongo:7.0", - Command = new List { "/bin/sh", "-c" }, - Args = new List - { - $"until mongosh '{connectionString}' --eval 'rs.status()' 2>/dev/null; do echo 'Waiting for MongoDB...'; sleep 5; done" - } - } - }, - Containers = new List - { - new() - { - Name = "mongorestore", - Image = "mongo:7.0", - Command = new List { "/bin/sh", "-c" }, - Args = new List - { - $"mongorestore --uri='{connectionString}' --archive=/tmp/backup.gz --gzip --drop" - }, - Env = new List - { - new() { Name = "BACKUP_PATH", Value = backupPath }, - new() { Name = "MINIO_ENDPOINT", Value = spec.MinioEndpoint } - } - } - } - } - } - } - }; - - await client.BatchV1.CreateNamespacedJobAsync(restoreJob, spec.Namespace, cancellationToken: ct); - - logger.LogInformation( - "Created restored MongoDBCommunity '{Name}' from source '{Source}'", - spec.RestoredClusterName, spec.SourceClusterName); - } - - public async Task DiagnoseBackupsAsync( - string kubeConfig, string contextName, string clusterName, string ns, CancellationToken ct = default) - { - Kubernetes client = BuildClient(kubeConfig, contextName); - - List cronJobs = new(); - List recentJobs = new(); - - try - { - // List CronJobs for this cluster. - - V1CronJobList cronJobList = await client.BatchV1.ListNamespacedCronJobAsync( - namespaceParameter: ns, - labelSelector: $"entkube.io/cluster={clusterName}", - cancellationToken: ct); - - foreach (V1CronJob cj in cronJobList.Items) - { - cronJobs.Add(new MongoBackupCronJobSummary( - Name: cj.Metadata.Name, - Namespace: cj.Metadata.NamespaceProperty, - Schedule: cj.Spec.Schedule, - LastScheduleTime: cj.Status?.LastScheduleTime?.ToString("o"), - Suspended: cj.Spec.Suspend ?? false)); - } - - // List recent Jobs. - - V1JobList jobList = await client.BatchV1.ListNamespacedJobAsync( - namespaceParameter: ns, - labelSelector: $"entkube.io/cluster={clusterName},entkube.io/operation=backup", - cancellationToken: ct); - - foreach (V1Job job in jobList.Items.OrderByDescending(j => j.Status?.StartTime).Take(10)) - { - string status = job.Status?.Succeeded > 0 ? "Completed" - : job.Status?.Failed > 0 ? "Failed" - : "Running"; - - recentJobs.Add(new MongoBackupJobSummary( - Name: job.Metadata.Name, - Namespace: job.Metadata.NamespaceProperty, - Status: status, - StartedAt: job.Status?.StartTime, - CompletedAt: job.Status?.CompletionTime)); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to diagnose backups for '{Cluster}'", clusterName); - } - - return new MongoBackupDiagnostics( - ClusterName: clusterName, - Namespace: ns, - LastBackup: recentJobs.FirstOrDefault(j => j.Status == "Completed")?.CompletedAt?.ToString("o"), - TotalBackupsInBucket: recentJobs.Count(j => j.Status == "Completed"), - CronJobs: cronJobs, - RecentJobs: recentJobs); - } - - // ─── Private Helpers ────────────────────────────────────────────────────── - - /// - /// Builds a MongoDBCommunity CR manifest. The Community Operator watches these - /// and creates StatefulSets with MongoDB replica set members, SCRAM authentication, - /// and persistent storage. - /// - private static object BuildMongoClusterManifest(MongoClusterSpec spec) - { - return new - { - apiVersion = $"{MongoGroup}/{MongoVersion}", - kind = "MongoDBCommunity", - metadata = new - { - name = spec.Name, - @namespace = spec.Namespace, - labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - spec = new - { - members = spec.Members, - type = "ReplicaSet", - version = spec.MongoVersion, - security = new - { - authentication = new - { - modes = new[] { "SCRAM" } - } - }, - users = new[] - { - new - { - name = "admin", - db = "admin", - passwordSecretRef = new - { - name = $"{spec.Name}-admin-password" - }, - roles = new[] - { - new { name = "clusterAdmin", db = "admin" }, - new { name = "userAdminAnyDatabase", db = "admin" }, - new { name = "readWriteAnyDatabase", db = "admin" }, - new { name = "dbAdminAnyDatabase", db = "admin" } - }, - scramCredentialsSecretName = $"{spec.Name}-admin-my-user" - } - }, - statefulSet = new - { - spec = new - { - volumeClaimTemplates = new[] - { - new - { - metadata = new - { - name = "data-volume" - }, - spec = new - { - accessModes = new[] { "ReadWriteOnce" }, - resources = new - { - requests = new Dictionary - { - ["storage"] = spec.StorageSize - } - } - } - } - } - } - } - } - }; - } - - /// - /// Builds a CronJob for scheduled mongodump backups to MinIO. - /// The Job runs mongodump against the secondary, compresses the output, - /// and uses mc (MinIO Client) to upload it. - /// - private static V1CronJob BuildBackupCronJob(MongoClusterSpec spec) - { - string connectionString = $"mongodb://{spec.Name}-svc.{spec.Namespace}.svc.cluster.local:27017/?replicaSet={spec.Name}&readPreference=secondary"; - - return new V1CronJob - { - Metadata = new V1ObjectMeta - { - Name = $"{spec.Name}-backup", - NamespaceProperty = spec.Namespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["entkube.io/operation"] = "backup", - ["entkube.io/cluster"] = spec.Name - } - }, - Spec = new V1CronJobSpec - { - Schedule = spec.BackupSchedule, - ConcurrencyPolicy = "Forbid", - SuccessfulJobsHistoryLimit = 3, - FailedJobsHistoryLimit = 3, - JobTemplate = new V1JobTemplateSpec - { - Spec = new V1JobSpec - { - TtlSecondsAfterFinished = 86400, - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta - { - Labels = new Dictionary - { - ["entkube.io/operation"] = "backup", - ["entkube.io/cluster"] = spec.Name - } - }, - Spec = new V1PodSpec - { - RestartPolicy = "OnFailure", - Containers = new List - { - new() - { - Name = "mongodump", - Image = "mongo:7.0", - Command = new List { "/bin/sh", "-c" }, - Args = new List - { - $"TIMESTAMP=$(date +%Y%m%d%H%M%S) && " + - $"mongodump --uri='{connectionString}' " + - "--archive=/tmp/backup-$TIMESTAMP.gz --gzip --oplog && " + - "echo \"Backup $TIMESTAMP completed successfully\"" - }, - Env = new List - { - new() { Name = "MINIO_ENDPOINT", Value = spec.MinioEndpoint }, - new() { Name = "MINIO_BUCKET", Value = spec.MinioBucket }, - new() { Name = "RETENTION_DAYS", Value = spec.BackupRetentionDays.ToString() } - }, - EnvFrom = new List - { - new() - { - SecretRef = new V1SecretEnvSource - { - Name = spec.MinioCredentialsSecret - } - } - } - } - } - } - } - } - } - } - }; - } - - private static Kubernetes BuildClient(string kubeConfig, string contextName) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(kubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, contextName); - return new Kubernetes(config); - } - - private async Task EnsureNamespaceAsync(Kubernetes client, string namespaceName, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(namespaceName, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - V1Namespace ns = new() - { - Metadata = new V1ObjectMeta - { - Name = namespaceName, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - - await client.CoreV1.CreateNamespaceAsync(ns, cancellationToken: ct); - logger.LogInformation("Created namespace '{Namespace}' for MongoDB cluster", namespaceName); - } - } - - /// - /// Creates the admin password secret that the MongoDBCommunity CR references - /// via passwordSecretRef. The operator reads the "password" key from this - /// secret to configure SCRAM authentication for the admin user. If the secret - /// already exists (e.g. from a previous attempt), we leave it as-is to avoid - /// rotating credentials unexpectedly. - /// - private async Task EnsureAdminPasswordSecretAsync( - Kubernetes client, string clusterName, string targetNamespace, CancellationToken ct) - { - string secretName = $"{clusterName}-admin-password"; - - // Check if the secret already exists — don't overwrite existing credentials. - - try - { - await client.CoreV1.ReadNamespacedSecretAsync(secretName, targetNamespace, cancellationToken: ct); - return; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Secret doesn't exist yet — we'll create it below. - } - - // Generate a secure random password for the MongoDB admin user. - - string password = Convert.ToBase64String( - System.Security.Cryptography.RandomNumberGenerator.GetBytes(24)); - - V1Secret secret = new() - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "mongodb" - } - }, - Type = "Opaque", - Data = new Dictionary - { - ["password"] = Encoding.UTF8.GetBytes(password) - } - }; - - await client.CoreV1.CreateNamespacedSecretAsync(secret, targetNamespace, cancellationToken: ct); - } - - /// - /// Ensures the service account used by the MongoDB StatefulSet pods exists. - /// The operator creates a StatefulSet with serviceAccountName="{name}-database" - /// but does NOT create the service account itself — the pods fail with - /// "forbidden: error looking up service account" if it's missing. - /// - private async Task EnsureServiceAccountAsync( - Kubernetes client, string serviceAccountName, string targetNamespace, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespacedServiceAccountAsync( - serviceAccountName, targetNamespace, cancellationToken: ct); - return; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Doesn't exist yet — create it below. - } - - V1ServiceAccount sa = new() - { - Metadata = new V1ObjectMeta - { - Name = serviceAccountName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "mongodb" - } - } - }; - - await client.CoreV1.CreateNamespacedServiceAccountAsync(sa, targetNamespace, cancellationToken: ct); - } - - /// - /// Creates or updates the Role and RoleBinding that grant the MongoDB service - /// account access to secrets and configmaps in its namespace. The operator's - /// agent sidecar reads the automation-config secret and the MongoDB readiness - /// probe fails without these permissions. - /// - private async Task EnsureServiceAccountRbacAsync( - Kubernetes client, string clusterName, string targetNamespace, CancellationToken ct) - { - string saName = $"{clusterName}-database"; - string roleName = $"{clusterName}-database"; - string bindingName = $"{clusterName}-database"; - - // Role: allow get/list/watch on secrets and configmaps in the namespace. - - V1Role role = new() - { - Metadata = new V1ObjectMeta - { - Name = roleName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "mongodb" - } - }, - Rules = new List - { - new() - { - ApiGroups = new List { "" }, - Resources = new List { "secrets", "configmaps" }, - Verbs = new List { "get", "list", "watch", "create", "update", "patch" } - } - } - }; - - try - { - await client.RbacAuthorizationV1.CreateNamespacedRoleAsync(role, targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - await client.RbacAuthorizationV1.ReplaceNamespacedRoleAsync(role, roleName, targetNamespace, cancellationToken: ct); - } - - // RoleBinding: bind the role to the service account. - - V1RoleBinding binding = new() - { - Metadata = new V1ObjectMeta - { - Name = bindingName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "mongodb" - } - }, - RoleRef = new V1RoleRef - { - ApiGroup = "rbac.authorization.k8s.io", - Kind = "Role", - Name = roleName - }, - Subjects = new List - { - new() - { - Kind = "ServiceAccount", - Name = saName, - NamespaceProperty = targetNamespace - } - } - }; - - try - { - await client.RbacAuthorizationV1.CreateNamespacedRoleBindingAsync(binding, targetNamespace, cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - await client.RbacAuthorizationV1.ReplaceNamespacedRoleBindingAsync(binding, bindingName, targetNamespace, cancellationToken: ct); - } - } - - /// - /// Ensures the backup credentials secret exists in the target namespace. - /// Tries to copy from cnpg-system first (shared secret), then falls back - /// to creating from raw credentials if provided. - /// - private async Task EnsureBackupSecretInNamespaceAsync( - Kubernetes client, string secretName, string targetNamespace, CancellationToken ct, - string? accessKey = null, string? secretKey = null, string? region = null, - string? endpoint = null, string? bucket = null) - { - // Check if the secret already exists in the target namespace. - - try - { - await client.CoreV1.ReadNamespacedSecretAsync(secretName, targetNamespace, cancellationToken: ct); - return; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Not found — continue to create it. - } - - // Try to copy from cnpg-system (shared backup credentials). - - V1Secret? sourceSecret = null; - - try - { - sourceSecret = await client.CoreV1.ReadNamespacedSecretAsync( - secretName, "cnpg-system", cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogWarning( - "Backup credentials secret '{SecretName}' not found in cnpg-system for MongoDB cluster", - secretName); - } - - // Build secret data from source or raw credentials. - - Dictionary? secretData = null; - - if (sourceSecret?.Data is not null) - { - secretData = sourceSecret.Data.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - else if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey)) - { - secretData = new Dictionary - { - ["ACCESS_KEY_ID"] = Encoding.UTF8.GetBytes(accessKey), - ["ACCESS_SECRET_KEY"] = Encoding.UTF8.GetBytes(secretKey) - }; - - if (!string.IsNullOrWhiteSpace(region)) - { - secretData["AWS_DEFAULT_REGION"] = Encoding.UTF8.GetBytes(region); - } - - if (!string.IsNullOrWhiteSpace(endpoint)) - { - secretData["ENDPOINT_URL"] = Encoding.UTF8.GetBytes(endpoint); - } - - if (!string.IsNullOrWhiteSpace(bucket)) - { - secretData["BUCKET_NAME"] = Encoding.UTF8.GetBytes(bucket); - } - - logger.LogInformation( - "Creating backup credentials secret '{SecretName}' in '{Namespace}' from raw credentials", - secretName, targetNamespace); - } - else - { - logger.LogError( - "Cannot create backup credentials secret '{SecretName}' in '{Namespace}' — " + - "not found in cnpg-system and no raw credentials provided. MongoDB backups will fail.", - secretName, targetNamespace); - return; - } - - V1Secret targetSecret = new() - { - Metadata = new V1ObjectMeta - { - Name = secretName, - NamespaceProperty = targetNamespace, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube", - ["app.kubernetes.io/component"] = "mongo-backup" - } - }, - Data = secretData - }; - - try - { - await client.CoreV1.CreateNamespacedSecretAsync( - targetSecret, targetNamespace, cancellationToken: ct); - - logger.LogInformation( - "Created backup credentials secret '{SecretName}' in '{Namespace}'", - secretName, targetNamespace); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.Conflict) - { - await client.CoreV1.ReplaceNamespacedSecretAsync( - targetSecret, secretName, targetNamespace, cancellationToken: ct); - } - } - - private static DiscoveredMongoCluster? ParseDiscoveredCluster(JsonElement item) - { - try - { - JsonElement metadata = item.GetProperty("metadata"); - string name = metadata.GetProperty("name").GetString() ?? ""; - string ns = metadata.TryGetProperty("namespace", out JsonElement nsEl) - ? nsEl.GetString() ?? "default" - : "default"; - - JsonElement spec = item.GetProperty("spec"); - - string version = spec.TryGetProperty("version", out JsonElement versionEl) - ? versionEl.GetString() ?? "unknown" - : "unknown"; - - int members = spec.TryGetProperty("members", out JsonElement membersEl) - ? membersEl.GetInt32() - : 1; - - // Extract storage size from StatefulSet volume claim templates. - - string storageSize = "10Gi"; - - if (spec.TryGetProperty("statefulSet", out JsonElement stsSpec) && - stsSpec.TryGetProperty("spec", out JsonElement stsInner) && - stsInner.TryGetProperty("volumeClaimTemplates", out JsonElement vcts) && - vcts.GetArrayLength() > 0) - { - JsonElement firstVct = vcts[0]; - - if (firstVct.TryGetProperty("spec", out JsonElement vctSpec) && - vctSpec.TryGetProperty("resources", out JsonElement resources) && - resources.TryGetProperty("requests", out JsonElement requests) && - requests.TryGetProperty("storage", out JsonElement storageEl)) - { - storageSize = storageEl.GetString() ?? "10Gi"; - } - } - - // Extract configured databases from users. - - List databases = new(); - - if (spec.TryGetProperty("users", out JsonElement users)) - { - foreach (JsonElement user in users.EnumerateArray()) - { - if (user.TryGetProperty("db", out JsonElement dbEl)) - { - string db = dbEl.GetString() ?? ""; - - if (!string.IsNullOrEmpty(db) && db != "admin") - { - databases.Add(db); - } - } - } - } - - return new DiscoveredMongoCluster( - Name: name, - Namespace: ns, - MongoVersion: version, - Members: members, - StorageSize: storageSize, - Databases: databases, - MinioEndpoint: null, - MinioBucket: null, - MinioCredentialsSecret: null); - } - catch - { - return null; - } - } - - private static async Task ExecInPodAsync( - Kubernetes client, string podName, string ns, string containerName, - string[] command, CancellationToken ct) - { - System.Net.WebSockets.WebSocket webSocket = await client.WebSocketNamespacedPodExecAsync( - name: podName, - @namespace: ns, - command: command, - container: containerName, - stderr: true, - stdin: false, - stdout: true, - tty: false, - cancellationToken: ct); - - using StreamDemuxer demuxer = new(webSocket); - demuxer.Start(); - - using Stream stdout = demuxer.GetStream(1, 1); - using StreamReader reader = new(stdout); - - string output = await reader.ReadToEndAsync(ct); - return output; - } - - /// - /// Escapes a string for safe use in MongoDB shell commands. - /// Prevents injection by removing dangerous characters. - /// - private static string EscapeMongoString(string input) - { - return input - .Replace("'", "") - .Replace("\"", "") - .Replace("\\", "") - .Replace(";", "") - .Replace("$", "") - .Replace("{", "") - .Replace("}", ""); - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/KubernetesRedisClusterClient.cs b/src/EntKube.Provisioning/Infrastructure/KubernetesRedisClusterClient.cs deleted file mode 100644 index dcadd32..0000000 --- a/src/EntKube.Provisioning/Infrastructure/KubernetesRedisClusterClient.cs +++ /dev/null @@ -1,947 +0,0 @@ -using System.Text; -using System.Text.Json; -using EntKube.Provisioning.Features.RedisClusters; -using k8s; -using k8s.Models; -using Microsoft.Extensions.Logging; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// Implementation of IRedisClusterClient that talks to the Kubernetes API using -/// the official .NET KubernetesClient. Manages OT-OPERATORS Redis custom resources: -/// -/// - redis.redis.opstreelabs.in/v1beta2 Redis — standalone or replicated instance -/// - redis.redis.opstreelabs.in/v1beta2 RedisSentinel — sentinel HA layer -/// -/// The OT-OPERATORS Redis Operator watches these CRs and reconciles them into -/// running Redis deployments with replication and optional sentinel failover. -/// -public class KubernetesRedisClusterClient : IRedisClusterClient -{ - private readonly ILogger logger; - - private const string RedisGroup = "redis.redis.opstreelabs.in"; - private const string RedisVersion = "v1beta2"; - private const string RedisPlural = "redis"; - private const string ReplicationPlural = "redisreplications"; - private const string SentinelPlural = "redissentinels"; - - public KubernetesRedisClusterClient(ILogger logger) - { - this.logger = logger; - } - - public async Task> DiscoverClustersAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // Connect to the K8s cluster and list all Redis and RedisReplication CRs - // across all namespaces. Each CR represents a running Redis instance. - - Kubernetes client = BuildClient(kubeConfig, contextName); - List discovered = new(); - - try - { - // Also check for sentinel resources to determine HA mode. - - HashSet sentinelNames = await GetSentinelNamesAsync(client, ct); - - // List RedisReplication CRs (multi-node setups). - - try - { - object replResult = await client.CustomObjects.ListClusterCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - plural: ReplicationPlural, - cancellationToken: ct); - - JsonElement replJson = JsonSerializer.SerializeToElement(replResult); - - if (replJson.TryGetProperty("items", out JsonElement replItems)) - { - foreach (JsonElement item in replItems.EnumerateArray()) - { - DiscoveredRedisCluster? cluster = ParseDiscoveredCluster(item, sentinelNames); - - if (cluster is not null) - { - discovered.Add(cluster); - } - } - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogDebug("RedisReplication CRD not found on cluster"); - } - - // List standalone Redis CRs. - - try - { - object result = await client.CustomObjects.ListClusterCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - plural: RedisPlural, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (json.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement item in items.EnumerateArray()) - { - DiscoveredRedisCluster? cluster = ParseDiscoveredCluster(item, sentinelNames); - - if (cluster is not null) - { - discovered.Add(cluster); - } - } - } - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogDebug("Redis CRD not found on cluster"); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to discover Redis instances"); - } - - return discovered; - } - - public async Task CreateClusterAsync( - string kubeConfig, string contextName, RedisClusterSpec spec, CancellationToken ct = default) - { - // The OpsTree operator uses different CRD kinds depending on topology: - // - "Redis" (plural: redis) → standalone single instance - // - "RedisReplication" (plural: redisreplications) → master + replicas - // Sentinel requires a RedisReplication to watch. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - await EnsureNamespaceAsync(client, spec.Namespace, ct); - - bool useReplication = spec.Replicas > 1 || spec.SentinelEnabled; - - if (useReplication) - { - object replicationManifest = BuildReplicationManifest(spec); - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: replicationManifest, - group: RedisGroup, - version: RedisVersion, - namespaceParameter: spec.Namespace, - plural: ReplicationPlural, - cancellationToken: ct); - } - else - { - object redisManifest = BuildStandaloneManifest(spec); - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: redisManifest, - group: RedisGroup, - version: RedisVersion, - namespaceParameter: spec.Namespace, - plural: RedisPlural, - cancellationToken: ct); - } - - // If sentinel is enabled, also create a RedisSentinel CR. - - if (spec.SentinelEnabled) - { - object sentinelManifest = BuildSentinelManifest(spec); - - await client.CustomObjects.CreateNamespacedCustomObjectAsync( - body: sentinelManifest, - group: RedisGroup, - version: RedisVersion, - namespaceParameter: spec.Namespace, - plural: SentinelPlural, - cancellationToken: ct); - } - - logger.LogInformation( - "Created Redis '{Name}' in namespace '{Namespace}' with {Replicas} replicas (sentinel={Sentinel}, replication={Replication})", - spec.Name, spec.Namespace, spec.Replicas, spec.SentinelEnabled, useReplication); - } - - public async Task UpdateClusterAsync( - string kubeConfig, string contextName, RedisClusterSpec spec, CancellationToken ct = default) - { - // Patch the Redis/RedisReplication CR with updated spec. - // Try replication first, fall back to standalone. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - bool useReplication = spec.Replicas > 1 || spec.SentinelEnabled; - string plural = useReplication ? ReplicationPlural : RedisPlural; - object patchBody = useReplication ? BuildReplicationManifest(spec) : BuildStandaloneManifest(spec); - - try - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patchBody), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: spec.Namespace, - plural: plural, - name: spec.Name, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // The CR might be the other kind — try the alternate plural. - - string altPlural = useReplication ? RedisPlural : ReplicationPlural; - - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patchBody), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: spec.Namespace, - plural: altPlural, - name: spec.Name, - cancellationToken: ct); - } - - logger.LogInformation("Updated Redis '{Name}' in namespace '{Namespace}'", spec.Name, spec.Namespace); - } - - public async Task UpgradeClusterAsync( - string kubeConfig, string contextName, string name, string ns, string targetVersion, CancellationToken ct = default) - { - // Patch the Redis/RedisReplication CR with the new image tag. The operator - // handles the rolling update, restarting pods one at a time. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - Dictionary patch = new() - { - ["spec"] = new Dictionary - { - ["kubernetesConfig"] = new Dictionary - { - ["image"] = $"quay.io/opstree/redis:v{targetVersion}" - } - } - }; - - // Try RedisReplication first, then standalone Redis. - - bool patched = false; - - try - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patch), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: ReplicationPlural, - name: name, - cancellationToken: ct); - patched = true; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Not a replication — try standalone. - } - - if (!patched) - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patch), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: RedisPlural, - name: name, - cancellationToken: ct); - } - - logger.LogInformation("Upgrading Redis '{Name}' to version {Version}", name, targetVersion); - } - - public async Task GetClusterStatusAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // The OpsTree operator CRDs don't have a "phase" field. - // - RedisReplication: status.masterNode (the elected master pod name) - // - Redis (standalone): status is empty - // We derive the phase from the masterNode and pod states instead. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - JsonElement json = default; - bool found = false; - bool isReplication = false; - - // Try RedisReplication first (multi-node), then standalone Redis. - - foreach (string plural in new[] { ReplicationPlural, RedisPlural }) - { - try - { - object result = await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: plural, - name: name, - cancellationToken: ct); - - json = JsonSerializer.SerializeToElement(result); - found = true; - isReplication = plural == ReplicationPlural; - break; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Try the next kind. - } - } - - if (!found) - { - return null; - } - - // For RedisReplication, the status contains masterNode. - - string? masterNode = null; - - if (isReplication && - json.TryGetProperty("status", out JsonElement status) && - status.TryGetProperty("masterNode", out JsonElement masterEl)) - { - masterNode = masterEl.GetString(); - } - - // Derive phase from pod state since the CRD doesn't have one. - - string phase; - - if (!string.IsNullOrEmpty(masterNode)) - { - phase = "Ready"; - } - else - { - // Check if pods exist and are running. - - try - { - V1PodList pods = await client.CoreV1.ListNamespacedPodAsync( - ns, - labelSelector: $"app={name}", - cancellationToken: ct); - - if (pods.Items.Count > 0 && pods.Items.All(p => p.Status?.Phase == "Running")) - { - phase = "Ready"; - } - else if (pods.Items.Count > 0) - { - phase = "Bootstrapping"; - } - else - { - phase = "Pending"; - } - } - catch - { - phase = "Unknown"; - } - } - - return new RedisCrdStatus( - Phase: phase, - ReadyReplicas: 0, - DesiredReplicas: 0, - CurrentMaster: masterNode); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to read Redis status for '{Name}' in '{Namespace}'", name, ns); - return null; - } - } - - public Task> GetAvailableVersionsAsync( - string kubeConfig, string contextName, CancellationToken ct = default) - { - // Return a static list of commonly supported Redis versions. - // In production, this could query a container registry for available tags. - - List versions = new() - { - "6.2.14", "7.0.15", "7.2.5", "7.4.1" - }; - - return Task.FromResult(versions); - } - - public async Task GetClusterHealthAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Read the Redis CR status and its pods to build a health snapshot. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - try - { - RedisCrdStatus? crdStatus = await GetClusterStatusAsync(kubeConfig, contextName, name, ns, ct); - - // Find Redis pods for this instance. - - V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( - ns, - labelSelector: $"app={name}", - cancellationToken: ct); - - List replicas = new(); - - foreach (V1Pod pod in podList.Items) - { - bool ready = pod.Status?.ContainerStatuses?.All(c => c.Ready) ?? false; - - // The OpsTree operator uses "redis-role" label (not "role"). - - string podRole = "unknown"; - - if (pod.Metadata.Labels?.TryGetValue("redis-role", out string? redisRole) == true) - { - podRole = redisRole; - } - else if (pod.Metadata.Labels?.TryGetValue("role", out string? legacyRole) == true) - { - podRole = legacyRole; - } - - replicas.Add(new RedisReplicaInfo( - Name: pod.Metadata.Name, - Role: podRole, - Ready: ready, - ReplicationLagBytes: null, - MemoryUsed: null, - PodPhase: pod.Status?.Phase)); - } - - // Check if sentinel is running for this instance. - // The OpsTree operator labels sentinel pods with app={name}-sentinel. - // Also try redis_setup_type=sentinel as an alternative label. - - bool sentinelActive = false; - - try - { - V1PodList sentinelPods = await client.CoreV1.ListNamespacedPodAsync( - ns, - labelSelector: $"app={name}-sentinel", - cancellationToken: ct); - - if (sentinelPods.Items.Count == 0) - { - // Try alternative label used by some operator versions. - - sentinelPods = await client.CoreV1.ListNamespacedPodAsync( - ns, - labelSelector: "redis_setup_type=sentinel", - cancellationToken: ct); - } - - sentinelActive = sentinelPods.Items.Any(p => p.Status?.Phase == "Running"); - } - catch { } - - // If no running sentinel pods, check if a RedisSentinel CR exists - // (it might still be spinning up). - - if (!sentinelActive) - { - try - { - await client.CustomObjects.GetNamespacedCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: SentinelPlural, - name: $"{name}-sentinel", - cancellationToken: ct); - - // CR exists but pods aren't ready yet — report as "starting". - sentinelActive = true; - } - catch (k8s.Autorest.HttpOperationException) { } - catch { } - } - - return new RedisClusterHealth( - Phase: crdStatus?.Phase ?? "Unknown", - ReadyReplicas: replicas.Count(r => r.Ready), - DesiredReplicas: replicas.Count, - CurrentMaster: replicas.FirstOrDefault(r => r.Role == "master")?.Name, - MemoryUsage: null, - ConnectedClients: null, - SentinelActive: sentinelActive, - ReplicaDetails: replicas); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to read health for Redis '{Name}'", name); - return null; - } - } - - public async Task RestartClusterAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Annotate the Redis/RedisReplication CR to trigger a rolling restart. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - Dictionary patch = new() - { - ["metadata"] = new Dictionary - { - ["annotations"] = new Dictionary - { - ["redis.opstreelabs.in/restart"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() - } - } - }; - - bool patched = false; - - try - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patch), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: ReplicationPlural, - name: name, - cancellationToken: ct); - patched = true; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Not a replication — try standalone. - } - - if (!patched) - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patch), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: RedisPlural, - name: name, - cancellationToken: ct); - } - - logger.LogInformation("Rolling restart initiated for Redis '{Name}'", name); - } - - public async Task FailoverAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // For sentinel-managed Redis, trigger a failover by annotating the CR. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - Dictionary patch = new() - { - ["metadata"] = new Dictionary - { - ["annotations"] = new Dictionary - { - ["redis.opstreelabs.in/failover"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() - } - } - }; - - // Failover only makes sense for replication setups, but try both. - - bool patched = false; - - try - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patch), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: ReplicationPlural, - name: name, - cancellationToken: ct); - patched = true; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Not a replication — try standalone. - } - - if (!patched) - { - await client.CustomObjects.PatchNamespacedCustomObjectAsync( - body: new V1Patch(JsonSerializer.Serialize(patch), V1Patch.PatchType.MergePatch), - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: RedisPlural, - name: name, - cancellationToken: ct); - } - - logger.LogInformation("Failover initiated for Redis '{Name}'", name); - } - - public async Task DeleteClusterAsync( - string kubeConfig, string contextName, string name, string ns, CancellationToken ct = default) - { - // Delete the Redis/RedisReplication CR. The operator will clean up pods, - // services, and PVCs. If the resource is already gone (404), treat as success. - - Kubernetes client = BuildClient(kubeConfig, contextName); - - // Try deleting as RedisReplication first, then standalone Redis. - - bool deleted = false; - - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: ReplicationPlural, - name: name, - cancellationToken: ct); - deleted = true; - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - // Not a replication — try standalone. - } - - if (!deleted) - { - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: RedisPlural, - name: name, - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogInformation("Redis CR '{Name}' already removed from namespace '{Namespace}'", name, ns); - } - } - - // Also try to delete any associated sentinel CR. - - try - { - await client.CustomObjects.DeleteNamespacedCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - namespaceParameter: ns, - plural: SentinelPlural, - name: $"{name}-sentinel", - cancellationToken: ct); - } - catch (k8s.Autorest.HttpOperationException ex) when (ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound) - { - logger.LogDebug("Sentinel CR '{Name}-sentinel' not found — skipping", name); - } - - logger.LogInformation("Deleted Redis '{Name}' from namespace '{Namespace}'", name, ns); - } - - // ─── Private helpers ────────────────────────────────────────────────────── - - private async Task> GetSentinelNamesAsync(Kubernetes client, CancellationToken ct) - { - HashSet names = new(); - - try - { - object result = await client.CustomObjects.ListClusterCustomObjectAsync( - group: RedisGroup, - version: RedisVersion, - plural: SentinelPlural, - cancellationToken: ct); - - JsonElement json = JsonSerializer.SerializeToElement(result); - - if (json.TryGetProperty("items", out JsonElement items)) - { - foreach (JsonElement item in items.EnumerateArray()) - { - if (item.TryGetProperty("metadata", out JsonElement meta) && - meta.TryGetProperty("name", out JsonElement name)) - { - // Sentinel names typically match "{redis-name}-sentinel" - string sentinelName = name.GetString() ?? ""; - string redisName = sentinelName.Replace("-sentinel", ""); - names.Add(redisName); - } - } - } - } - catch { } - - return names; - } - - private static DiscoveredRedisCluster? ParseDiscoveredCluster(JsonElement item, HashSet sentinelNames) - { - try - { - JsonElement metadata = item.GetProperty("metadata"); - string name = metadata.GetProperty("name").GetString()!; - string ns = metadata.GetProperty("namespace").GetString()!; - - JsonElement spec = item.GetProperty("spec"); - - // Extract version from image tag. - - string version = "unknown"; - - if (spec.TryGetProperty("kubernetesConfig", out JsonElement k8sConfig) && - k8sConfig.TryGetProperty("image", out JsonElement image)) - { - string imageStr = image.GetString() ?? ""; - - if (imageStr.Contains(':')) - { - version = imageStr.Split(':').Last().TrimStart('v'); - } - } - - // Extract replica count from the spec. - - int replicas = 1; - - if (spec.TryGetProperty("redisExporter", out _)) - { - // Standalone mode — 1 replica - } - - if (spec.TryGetProperty("clusterSize", out JsonElement size)) - { - replicas = size.TryGetInt32(out int s) ? s : 1; - } - - // Extract storage size. - - string storageSize = "1Gi"; - - if (spec.TryGetProperty("storage", out JsonElement storage) && - storage.TryGetProperty("volumeClaimTemplate", out JsonElement vct) && - vct.TryGetProperty("spec", out JsonElement vctSpec) && - vctSpec.TryGetProperty("resources", out JsonElement resources) && - resources.TryGetProperty("requests", out JsonElement requests) && - requests.TryGetProperty("storage", out JsonElement storageVal)) - { - storageSize = storageVal.GetString() ?? "1Gi"; - } - - bool sentinelEnabled = sentinelNames.Contains(name) || - spec.TryGetProperty("sentinel", out _); - - return new DiscoveredRedisCluster(name, ns, version, replicas, storageSize, sentinelEnabled); - } - catch - { - return null; - } - } - - private static object BuildStandaloneManifest(RedisClusterSpec spec) - { - return new Dictionary - { - ["apiVersion"] = $"{RedisGroup}/{RedisVersion}", - ["kind"] = "Redis", - ["metadata"] = new Dictionary - { - ["name"] = spec.Name, - ["namespace"] = spec.Namespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - ["spec"] = new Dictionary - { - ["kubernetesConfig"] = new Dictionary - { - ["image"] = $"quay.io/opstree/redis:v{spec.RedisVersion}", - ["imagePullPolicy"] = "IfNotPresent" - }, - ["podSecurityContext"] = new Dictionary - { - ["runAsUser"] = 1000, - ["fsGroup"] = 1000, - ["seccompProfile"] = new Dictionary - { - ["type"] = "RuntimeDefault" - } - }, - ["storage"] = new Dictionary - { - ["volumeClaimTemplate"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["accessModes"] = new[] { "ReadWriteOnce" }, - ["resources"] = new Dictionary - { - ["requests"] = new Dictionary - { - ["storage"] = spec.StorageSize - } - } - } - } - } - } - }; - } - - private static object BuildReplicationManifest(RedisClusterSpec spec) - { - return new Dictionary - { - ["apiVersion"] = $"{RedisGroup}/{RedisVersion}", - ["kind"] = "RedisReplication", - ["metadata"] = new Dictionary - { - ["name"] = spec.Name, - ["namespace"] = spec.Namespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - ["spec"] = new Dictionary - { - ["clusterSize"] = spec.Replicas, - ["kubernetesConfig"] = new Dictionary - { - ["image"] = $"quay.io/opstree/redis:v{spec.RedisVersion}", - ["imagePullPolicy"] = "IfNotPresent" - }, - ["podSecurityContext"] = new Dictionary - { - ["runAsUser"] = 1000, - ["fsGroup"] = 1000, - ["seccompProfile"] = new Dictionary - { - ["type"] = "RuntimeDefault" - } - }, - ["storage"] = new Dictionary - { - ["volumeClaimTemplate"] = new Dictionary - { - ["spec"] = new Dictionary - { - ["accessModes"] = new[] { "ReadWriteOnce" }, - ["resources"] = new Dictionary - { - ["requests"] = new Dictionary - { - ["storage"] = spec.StorageSize - } - } - } - } - } - } - }; - } - - private static object BuildSentinelManifest(RedisClusterSpec spec) - { - return new Dictionary - { - ["apiVersion"] = $"{RedisGroup}/{RedisVersion}", - ["kind"] = "RedisSentinel", - ["metadata"] = new Dictionary - { - ["name"] = $"{spec.Name}-sentinel", - ["namespace"] = spec.Namespace, - ["labels"] = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - }, - ["spec"] = new Dictionary - { - ["clusterSize"] = 3, - ["redisSentinelConfig"] = new Dictionary - { - ["redisReplicationName"] = spec.Name - }, - ["kubernetesConfig"] = new Dictionary - { - ["image"] = $"quay.io/opstree/redis-sentinel:v{spec.RedisVersion}", - ["imagePullPolicy"] = "IfNotPresent" - } - } - }; - } - - private static async Task EnsureNamespaceAsync(Kubernetes client, string ns, CancellationToken ct) - { - try - { - await client.CoreV1.ReadNamespaceAsync(ns, cancellationToken: ct); - } - catch - { - V1Namespace nsResource = new() - { - Metadata = new V1ObjectMeta - { - Name = ns, - Labels = new Dictionary - { - ["app.kubernetes.io/managed-by"] = "entkube" - } - } - }; - - await client.CoreV1.CreateNamespaceAsync(nsResource, cancellationToken: ct); - } - } - - private static Kubernetes BuildClient(string kubeConfig, string contextName) - { - byte[] kubeConfigBytes = Encoding.UTF8.GetBytes(kubeConfig); - using MemoryStream stream = new(kubeConfigBytes); - KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream, contextName); - return new Kubernetes(config); - } -} diff --git a/src/EntKube.Provisioning/Infrastructure/ProvisioningDbContext.cs b/src/EntKube.Provisioning/Infrastructure/ProvisioningDbContext.cs deleted file mode 100644 index 3e73f66..0000000 --- a/src/EntKube.Provisioning/Infrastructure/ProvisioningDbContext.cs +++ /dev/null @@ -1,445 +0,0 @@ -using System.Text.Json; -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Provisioning.Infrastructure; - -/// -/// The EF Core database context for the Provisioning service. Maps all -/// aggregate roots: ServiceInstance, PostgresCluster, MinioInstance, MinioTenant, -/// IdentityRealm, PrometheusStack, and GrafanaInstance. Complex nested types (records, value objects) -/// are stored as JSON columns. Collections with independent identity (RealmIdentityProvider, -/// RealmOrganization) get their own tables. -/// -public class ProvisioningDbContext : DbContext -{ - public DbSet ServiceInstances => Set(); - public DbSet PostgresClusters => Set(); - public DbSet MinioInstances => Set(); - public DbSet MinioTenants => Set(); - public DbSet IdentityRealms => Set(); - public DbSet PrometheusStacks => Set(); - public DbSet GrafanaInstances => Set(); - public DbSet MongoClusters => Set(); - public DbSet RedisClusters => Set(); - public DbSet Apps => Set(); - - public ProvisioningDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - ConfigureServiceInstance(modelBuilder); - ConfigurePostgresCluster(modelBuilder); - ConfigureMinioInstance(modelBuilder); - ConfigureMinioTenant(modelBuilder); - ConfigureIdentityRealm(modelBuilder); - ConfigurePrometheusStack(modelBuilder); - ConfigureGrafanaInstance(modelBuilder); - ConfigureMongoCluster(modelBuilder); - ConfigureRedisCluster(modelBuilder); - ConfigureApp(modelBuilder); - } - - private static void ConfigureServiceInstance(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(s => s.Id); - entity.Property(s => s.ClusterId); - entity.Property(s => s.EnvironmentId); - entity.Property(s => s.ServiceType).HasConversion().HasMaxLength(50); - entity.Property(s => s.Name).IsRequired().HasMaxLength(200); - entity.Property(s => s.Namespace).IsRequired().HasMaxLength(200); - entity.Property(s => s.DesiredState).HasConversion().HasMaxLength(50); - entity.Property(s => s.CurrentState).HasConversion().HasMaxLength(50); - entity.Property(s => s.CreatedAt); - entity.Property(s => s.LastReconcileAt); - }); - } - - private static void ConfigurePostgresCluster(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(p => p.Id); - entity.Property(p => p.ClusterId); - entity.Property(p => p.EnvironmentId); - entity.Property(p => p.Name).IsRequired().HasMaxLength(200); - entity.Property(p => p.Namespace).IsRequired().HasMaxLength(200); - entity.Property(p => p.PostgresVersion).IsRequired().HasMaxLength(50); - entity.Property(p => p.Instances); - entity.Property(p => p.StorageSize).IsRequired().HasMaxLength(50); - entity.Property(p => p.Status).HasConversion().HasMaxLength(50); - entity.Property(p => p.StatusMessage).HasMaxLength(1000); - entity.Property(p => p.TargetVersion).HasMaxLength(50); - entity.Property(p => p.CreatedAt); - entity.Property(p => p.LastReconcileAt); - - // BackupConfiguration is a record — store as JSON. - - entity.Property(p => p.BackupConfig) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - // Databases list stored as JSON. - - entity.Property>("databases") - .HasField("databases") - .HasColumnName("Databases") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(p => p.Databases); - }); - } - - private static void ConfigureMinioInstance(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(m => m.Id); - entity.Property(m => m.ClusterId); - entity.Property(m => m.EnvironmentId); - entity.Property(m => m.Name).IsRequired().HasMaxLength(200); - entity.Property(m => m.Namespace).IsRequired().HasMaxLength(200); - entity.Property(m => m.Endpoint).IsRequired().HasMaxLength(500); - entity.Property(m => m.ConsoleEndpoint).HasMaxLength(500); - entity.Property(m => m.CredentialsSecret).IsRequired().HasMaxLength(200); - entity.Property(m => m.TotalCapacity).HasMaxLength(50); - entity.Property(m => m.UsedCapacity).HasMaxLength(50); - entity.Property(m => m.Status).HasConversion().HasMaxLength(50); - entity.Property(m => m.StatusMessage).HasMaxLength(1000); - entity.Property(m => m.AdoptedAt); - entity.Property(m => m.LastReconcileAt); - - // Buckets list stored as JSON. - - entity.Property>("buckets") - .HasField("buckets") - .HasColumnName("Buckets") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(m => m.Buckets); - }); - } - - private static void ConfigureMinioTenant(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(t => t.Id); - entity.Property(t => t.ClusterId); - entity.Property(t => t.EnvironmentId); - entity.Property(t => t.Name).IsRequired().HasMaxLength(200); - entity.Property(t => t.Namespace).IsRequired().HasMaxLength(200); - entity.Property(t => t.Status).HasConversion().HasMaxLength(50); - entity.Property(t => t.StatusMessage).HasMaxLength(1000); - entity.Property(t => t.CreatedAt); - entity.Property(t => t.LastReconcileAt); - - // Pools stored as JSON — value objects without identity. - - entity.Property>("pools") - .HasField("pools") - .HasColumnName("Pools") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(t => t.Pools); - }); - } - - private static void ConfigureIdentityRealm(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(r => r.Id); - entity.Property(r => r.EnvironmentId); - entity.Property(r => r.ClusterId); - entity.Property(r => r.Name).IsRequired().HasMaxLength(200); - entity.Property(r => r.Status).HasConversion().HasMaxLength(50); - entity.Property(r => r.StatusMessage).HasMaxLength(1000); - entity.Property(r => r.CreatedAt); - entity.Property(r => r.LastModifiedAt); - - // Branding and Pages are records — store as JSON. - - entity.Property(r => r.Branding) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default) ?? RealmBranding.Default) - .HasColumnType("TEXT"); - - entity.Property(r => r.Pages) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default) ?? RealmPages.Default) - .HasColumnType("TEXT"); - - // Identity providers have their own table — they have Guid Id and behavior. - - entity.HasMany("identityProviders") - .WithOne() - .HasForeignKey("RealmId") - .OnDelete(DeleteBehavior.Cascade); - - entity.Ignore(r => r.IdentityProviders); - - // Organizations have their own table — hierarchical with children. - - entity.HasMany("organizations") - .WithOne() - .HasForeignKey("RealmId") - .OnDelete(DeleteBehavior.Cascade); - - entity.Ignore(r => r.Organizations); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(p => p.Id); - entity.Property("RealmId"); - entity.Property(p => p.Alias).IsRequired().HasMaxLength(200); - entity.Property(p => p.DisplayName).HasMaxLength(200); - entity.Property(p => p.Type).HasConversion().HasMaxLength(50); - entity.Property(p => p.Enabled); - entity.Property(p => p.AuthorizationUrl).HasMaxLength(500); - entity.Property(p => p.TokenUrl).HasMaxLength(500); - entity.Property(p => p.ClientId).HasMaxLength(200); - entity.Property(p => p.ClientSecret).HasMaxLength(500); - entity.Property(p => p.MetadataUrl).HasMaxLength(500); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(o => o.Id); - entity.Property("RealmId"); - entity.Property("ParentId"); - entity.Property(o => o.Name).IsRequired().HasMaxLength(200); - entity.Property(o => o.Description).HasMaxLength(1000); - - // Self-referencing hierarchy for child organizations. - - entity.HasMany("children") - .WithOne() - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Cascade); - - entity.Ignore(o => o.Children); - }); - } - - private static void ConfigurePrometheusStack(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(p => p.Id); - entity.Property(p => p.ClusterId); - entity.Property(p => p.EnvironmentId); - entity.Property(p => p.Name).IsRequired().HasMaxLength(200); - entity.Property(p => p.Namespace).IsRequired().HasMaxLength(200); - entity.Property(p => p.Status).HasConversion().HasMaxLength(50); - entity.Property(p => p.CreatedAt); - entity.Property(p => p.LastReconcileAt); - - // Prometheus and Alertmanager configs are records — store as JSON. - - entity.Property(p => p.Prometheus) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)!) - .HasColumnType("TEXT"); - - entity.Property(p => p.Alertmanager) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)!) - .HasColumnType("TEXT"); - - entity.Property(p => p.Ingress) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - }); - } - - private static void ConfigureGrafanaInstance(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(g => g.Id); - entity.Property(g => g.EnvironmentId); - entity.Property(g => g.ClusterId); - entity.Property(g => g.Name).IsRequired().HasMaxLength(200); - entity.Property(g => g.Namespace).IsRequired().HasMaxLength(200); - entity.Property(g => g.Status).HasConversion().HasMaxLength(50); - entity.Property(g => g.CreatedAt); - entity.Property(g => g.LastReconcileAt); - entity.Property(g => g.Persistence); - entity.Property(g => g.PersistenceSize).HasMaxLength(50); - - entity.Property(g => g.Oidc) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - entity.Property(g => g.Ingress) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - // Dashboards stored as JSON. - - entity.Property>("dashboards") - .HasField("dashboards") - .HasColumnName("Dashboards") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(g => g.Dashboards); - }); - } - - private static void ConfigureMongoCluster(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(m => m.Id); - entity.Property(m => m.ClusterId); - entity.Property(m => m.EnvironmentId); - entity.Property(m => m.Name).IsRequired().HasMaxLength(200); - entity.Property(m => m.Namespace).IsRequired().HasMaxLength(200); - entity.Property(m => m.MongoVersion).IsRequired().HasMaxLength(50); - entity.Property(m => m.Members); - entity.Property(m => m.StorageSize).IsRequired().HasMaxLength(50); - entity.Property(m => m.Status).HasConversion().HasMaxLength(50); - entity.Property(m => m.StatusMessage).HasMaxLength(1000); - entity.Property(m => m.TargetVersion).HasMaxLength(50); - entity.Property(m => m.CreatedAt); - entity.Property(m => m.LastReconcileAt); - - // MongoBackupConfiguration is a record — store as JSON. - - entity.Property(m => m.BackupConfig) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - // Databases list stored as JSON. - - entity.Property>("databases") - .HasField("databases") - .HasColumnName("Databases") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(m => m.Databases); - }); - } - - private static void ConfigureRedisCluster(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(r => r.Id); - entity.Property(r => r.ClusterId); - entity.Property(r => r.EnvironmentId); - entity.Property(r => r.Name).IsRequired().HasMaxLength(200); - entity.Property(r => r.Namespace).IsRequired().HasMaxLength(200); - entity.Property(r => r.RedisVersion).IsRequired().HasMaxLength(50); - entity.Property(r => r.Replicas); - entity.Property(r => r.StorageSize).IsRequired().HasMaxLength(50); - entity.Property(r => r.SentinelEnabled); - entity.Property(r => r.Status).HasConversion().HasMaxLength(50); - entity.Property(r => r.StatusMessage).HasMaxLength(1000); - entity.Property(r => r.TargetVersion).HasMaxLength(50); - entity.Property(r => r.CreatedAt); - entity.Property(r => r.LastReconcileAt); - }); - } - - private static void ConfigureApp(ModelBuilder modelBuilder) - { - modelBuilder.Entity(entity => - { - entity.HasKey(a => a.Id); - entity.Property(a => a.TenantId); - entity.Property(a => a.CustomerId); - entity.Property(a => a.Name).IsRequired().HasMaxLength(200); - entity.Property(a => a.Slug).IsRequired().HasMaxLength(200); - entity.Property(a => a.Type).HasConversion().HasMaxLength(50); - entity.Property(a => a.Status).HasConversion().HasMaxLength(50); - entity.Property(a => a.CreatedAt); - - // AppEnvironments are owned by the App aggregate. - - entity.HasMany("environments") - .WithOne() - .HasForeignKey(e => e.AppId) - .OnDelete(DeleteBehavior.Cascade); - - entity.Ignore(a => a.Environments); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.Property(e => e.AppId); - entity.Property(e => e.EnvironmentId); - entity.Property(e => e.ClusterId); - entity.Property(e => e.Namespace).IsRequired().HasMaxLength(200); - entity.Property(e => e.SyncStatus).HasConversion().HasMaxLength(50); - entity.Property(e => e.SyncStatusMessage).HasMaxLength(1000); - entity.Property(e => e.LastSyncAt); - - // DeploymentSpec and HelmReleaseSpec are records — store as JSON. - - entity.Property(e => e.DeploymentSpec) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - entity.Property(e => e.HelmReleaseSpec) - .HasConversion( - v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions.Default)) - .HasColumnType("TEXT"); - - // Secrets are value objects — store as JSON. - - entity.Property>("secrets") - .HasField("secrets") - .HasColumnName("Secrets") - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - - entity.Ignore(e => e.Secrets); - }); - } -} diff --git a/src/EntKube.Provisioning/Program.cs b/src/EntKube.Provisioning/Program.cs deleted file mode 100644 index b826beb..0000000 --- a/src/EntKube.Provisioning/Program.cs +++ /dev/null @@ -1,410 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.DecommissionService; -using EntKube.Provisioning.Features.GetServices; -using EntKube.Provisioning.Features.MinioInstances; -using EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance; -using EntKube.Provisioning.Features.MinioTenants; -using EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants; -using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant; -using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant; -using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant; -using EntKube.Provisioning.Features.PostgresClusters; -using EntKube.Provisioning.Features.PostgresClusters.AddDatabase; -using EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.RestoreBackup; -using EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster; -using EntKube.Provisioning.Features.MongoClusters; -using EntKube.Provisioning.Features.MongoClusters.AddDatabase; -using EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.RestoreBackup; -using EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster; -using EntKube.Provisioning.Features.RedisClusters; -using EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster; -using EntKube.Provisioning.Features.IdentityRealms; -using EntKube.Provisioning.Features.Monitoring; -using EntKube.Provisioning.Features.ProvisionService; -using EntKube.Provisioning.Features.Apps; -using EntKube.Provisioning.Features.Apps.CreateApp; -using EntKube.Provisioning.Features.Apps.GetApps; -using EntKube.Provisioning.Features.Apps.AddAppEnvironment; -using EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment; -using EntKube.Provisioning.Features.Apps.AddAppSecret; -using EntKube.Provisioning.Features.Apps.DeleteApp; -using EntKube.Provisioning.Features.Apps.RemoveAppEnvironment; -using EntKube.Provisioning.Features.Apps.RemoveAppSecret; -using EntKube.Provisioning.Features.Apps.SuspendActivateApp; -using EntKube.Provisioning.Features.Apps.TriggerSync; -using EntKube.Provisioning.Features.Apps.Reconcile; -using EntKube.Provisioning.Features.DatabaseStatusReconcile; -using EntKube.Provisioning.Infrastructure; -using Microsoft.EntityFrameworkCore; -using System.Text.Json.Serialization; - -namespace EntKube.Provisioning; - -public class Program -{ - public static void Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - // Allow enums to be serialized/deserialized as strings in JSON (e.g., "Deployment" instead of 0). - - builder.Services.ConfigureHttpJsonOptions(options => - { - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); - - // Register the database context. The provider is selected based on - // the DatabaseProvider config value: Sqlite (default), Postgres, or SqlServer. - - string provider = builder.Configuration["DatabaseProvider"] ?? "Sqlite"; - string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=App_Data/provisioning.db"; - - builder.Services.AddDbContext(options => - { - switch (provider.ToLowerInvariant()) - { - case "postgres": - options.UseNpgsql(connectionString); - break; - - case "sqlserver": - options.UseSqlServer(connectionString); - break; - - default: - options.UseSqlite(connectionString); - break; - } - }); - - // Register provisioning services with EF Core-backed repositories. - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // PostgreSQL cluster management (CNPG). - - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // MongoDB cluster management (Community Operator). - - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Redis cluster management (OT-OPERATORS Redis Operator). - - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // MinIO instance management (adoption-only). - - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - - // MinIO tenant management (full lifecycle: create, configure, delete). - - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Identity realm management. - - builder.Services.AddScoped(); - - // Monitoring: Prometheus stack (per-cluster) and Grafana instance (per-environment). - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - - // Customer app management (Deployment and Helm chart workloads). - - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - - // Database status reconciler — polls K8s to transition CNPG, Redis, and - // MongoDB clusters from Provisioning → Running once the operator reports healthy. - - builder.Services.AddSingleton(); - builder.Services.AddScoped(); - builder.Services.AddHostedService(); - - // Named HTTP clients for service-to-service calls. The reconciler - // needs the Clusters service for kubeconfig and the Secrets service - // for resolving vault keys. - - builder.Services.AddHttpClient("ClustersApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Clusters:BaseUrl"] ?? "http://localhost:5010"); - }); - - builder.Services.AddHttpClient("SecretsApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Secrets:BaseUrl"] ?? "http://localhost:5040"); - }); - - builder.Services.AddHealthChecks(); - builder.Services.AddOpenApi(); - - WebApplication app = builder.Build(); - - // Apply pending database migrations on startup. - - MigrateDatabase(app); - - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - } - - app.UseHttpsRedirection(); - - // Map feature endpoints. - - ProvisionServiceEndpoint.Map(app); - GetServicesEndpoint.Map(app); - DecommissionServiceEndpoint.Map(app); - PostgresClusterEndpoints.Map(app); - MongoClusterEndpoints.Map(app); - RedisClusterEndpoints.Map(app); - MinioInstanceEndpoints.Map(app); - MinioTenantEndpoints.Map(app); - IdentityRealmEndpoints.Map(app); - PrometheusStackEndpoints.Map(app); - GrafanaInstanceEndpoints.Map(app); - AppEndpoints.Map(app); - - app.MapHealthChecks("/health"); - app.MapHealthChecks("/health/ready"); - - app.Run(); - } - - private static void MigrateDatabase(WebApplication app) - { - using IServiceScope scope = app.Services.CreateScope(); - ProvisioningDbContext db = scope.ServiceProvider.GetRequiredService(); - ILogger logger = scope.ServiceProvider.GetRequiredService>(); - - // SQLite needs the directory to exist before it can create the database file. - // We extract the path from the connection string and ensure the folder is in place. - - string? connectionString = db.Database.GetConnectionString(); - - if (connectionString != null) - { - Microsoft.Data.Sqlite.SqliteConnectionStringBuilder csb = new(connectionString); - - if (!string.IsNullOrEmpty(csb.DataSource) && csb.DataSource != ":memory:") - { - string? directory = Path.GetDirectoryName(Path.GetFullPath(csb.DataSource)); - - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - } - } - - int maxRetries = 5; - int delayMs = 1000; - - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - db.Database.EnsureCreated(); - - // EnsureCreated only creates the DB if it doesn't exist at all. - // When the DB already exists but new tables were added to the - // DbContext (e.g. RedisClusters), we need to create them manually. - // This checks each entity type and creates missing tables. - - EnsureMissingTablesCreated(db, logger); - - logger.LogInformation("Database ready on attempt {Attempt}.", attempt); - return; - } - catch (Exception ex) when (attempt < maxRetries) - { - logger.LogWarning( - ex, - "Database setup attempt {Attempt}/{MaxRetries} failed. Retrying in {DelayMs}ms...", - attempt, - maxRetries, - delayMs); - - Thread.Sleep(delayMs); - delayMs *= 2; - } - } - } - - /// - /// Checks for tables that exist in the DbContext model but not in the database, - /// and creates them. This handles the case where new entity types are added - /// (e.g. RedisClusters) but the database file already exists from a previous run. - /// Only applies to SQLite — Postgres/SqlServer should use proper migrations. - /// - private static void EnsureMissingTablesCreated(ProvisioningDbContext db, ILogger logger) - { - if (!db.Database.IsSqlite()) - { - return; - } - - Microsoft.EntityFrameworkCore.Metadata.IModel model = db.Model; - - foreach (Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType in model.GetEntityTypes()) - { - string? tableName = entityType.GetTableName(); - - if (string.IsNullOrEmpty(tableName)) - { - continue; - } - - // Check if the table exists in SQLite by querying sqlite_master. - - try - { - using Microsoft.Data.Sqlite.SqliteCommand cmd = new( - $"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name", - (Microsoft.Data.Sqlite.SqliteConnection)db.Database.GetDbConnection()); - - if (cmd.Connection!.State != System.Data.ConnectionState.Open) - { - cmd.Connection.Open(); - } - - cmd.Parameters.AddWithValue("@name", tableName); - long count = (long)cmd.ExecuteScalar()!; - - if (count == 0) - { - logger.LogInformation("Creating missing table '{TableName}'...", tableName); - - // Generate the CREATE TABLE SQL from the EF model. - - string createSql = GenerateCreateTableSql(entityType); - db.Database.ExecuteSqlRaw(createSql); - logger.LogInformation("Table '{TableName}' created.", tableName); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not check/create table '{TableName}'", tableName); - } - } - } - - /// - /// Generates a CREATE TABLE statement from the EF Core entity type metadata. - /// Handles column types, nullability, and primary keys for SQLite. - /// - private static string GenerateCreateTableSql(Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType) - { - string tableName = entityType.GetTableName()!; - List columnDefs = new(); - List pkColumns = new(); - - Microsoft.EntityFrameworkCore.Metadata.IKey? primaryKey = entityType.FindPrimaryKey(); - - if (primaryKey is not null) - { - foreach (Microsoft.EntityFrameworkCore.Metadata.IProperty prop in primaryKey.Properties) - { - pkColumns.Add($"\"{prop.GetColumnName()}\""); - } - } - - foreach (Microsoft.EntityFrameworkCore.Metadata.IProperty property in entityType.GetProperties()) - { - string columnName = property.GetColumnName()!; - string columnType = property.GetColumnType() ?? MapClrTypeToSqlite(property.ClrType); - bool isNullable = property.IsNullable; - - string nullConstraint = isNullable ? "" : " NOT NULL"; - columnDefs.Add($"\"{columnName}\" {columnType}{nullConstraint}"); - } - - string columns = string.Join(",\n ", columnDefs); - string pkClause = pkColumns.Count > 0 - ? $",\n PRIMARY KEY ({string.Join(", ", pkColumns)})" - : ""; - - return $"CREATE TABLE \"{tableName}\" (\n {columns}{pkClause}\n);"; - } - - private static string MapClrTypeToSqlite(Type clrType) - { - Type underlying = Nullable.GetUnderlyingType(clrType) ?? clrType; - - if (underlying == typeof(Guid) || underlying == typeof(string)) - { - return "TEXT"; - } - - if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(bool)) - { - return "INTEGER"; - } - - if (underlying == typeof(decimal) || underlying == typeof(double) || underlying == typeof(float)) - { - return "REAL"; - } - - if (underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset)) - { - return "TEXT"; - } - - return "TEXT"; - } -} diff --git a/src/EntKube.Provisioning/Properties/launchSettings.json b/src/EntKube.Provisioning/Properties/launchSettings.json deleted file mode 100644 index fa328c8..0000000 --- a/src/EntKube.Provisioning/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5020", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7020;http://localhost:5020", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/EntKube.Provisioning/appsettings.Development.json b/src/EntKube.Provisioning/appsettings.Development.json deleted file mode 100644 index 97c77d6..0000000 --- a/src/EntKube.Provisioning/appsettings.Development.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Services": { - "Clusters": { - "BaseUrl": "http://localhost:5010" - }, - "Secrets": { - "BaseUrl": "http://localhost:5040" - } - } -} diff --git a/src/EntKube.Provisioning/appsettings.json b/src/EntKube.Provisioning/appsettings.json deleted file mode 100644 index d0111f5..0000000 --- a/src/EntKube.Provisioning/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/EntKube.Secrets/Crypto/AesGcmEncryptor.cs b/src/EntKube.Secrets/Crypto/AesGcmEncryptor.cs deleted file mode 100644 index e2106e2..0000000 --- a/src/EntKube.Secrets/Crypto/AesGcmEncryptor.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Security.Cryptography; - -namespace EntKube.Secrets.Crypto; - -/// -/// Provides AES-256-GCM authenticated encryption and decryption. This is the -/// lowest-level cryptographic building block in the secrets manager. Every -/// piece of secret data flows through this class before being stored. -/// -/// AES-GCM provides both confidentiality (encryption) and integrity -/// (authentication tag). If anyone tampers with the ciphertext, decryption -/// fails with a CryptographicException rather than returning garbage. -/// -/// Wire format: [12-byte nonce][ciphertext][16-byte auth tag] -/// The nonce is generated randomly for each encryption operation, ensuring -/// that encrypting the same plaintext twice produces different ciphertext. -/// -public static class AesGcmEncryptor -{ - private const int NonceSize = 12; // 96 bits — recommended for AES-GCM - private const int TagSize = 16; // 128 bits — maximum authentication strength - private const int KeySize = 32; // 256 bits — AES-256 - - /// - /// Encrypts plaintext using AES-256-GCM with a random nonce. - /// Returns the combined [nonce + ciphertext + tag] as a single byte array. - /// The nonce is safe to store alongside the ciphertext — it only needs - /// to be unique, not secret. - /// - public static byte[] Encrypt(byte[] key, byte[] plaintext) - { - // Generate a random 96-bit nonce. Using a CSPRNG for nonce generation - // means we don't need to track nonce counters across restarts. - - byte[] nonce = new byte[NonceSize]; - RandomNumberGenerator.Fill(nonce); - - byte[] ciphertext = new byte[plaintext.Length]; - byte[] tag = new byte[TagSize]; - - // Perform the authenticated encryption. The tag authenticates both - // the ciphertext and the nonce, so tampering with either is detected. - - using (AesGcm aes = new(key, TagSize)) - { - aes.Encrypt(nonce, plaintext, ciphertext, tag); - } - - // Pack everything into a single byte array: [nonce][ciphertext][tag]. - // This makes storage and transmission simple — one blob per secret. - - byte[] result = new byte[NonceSize + ciphertext.Length + TagSize]; - Buffer.BlockCopy(nonce, 0, result, 0, NonceSize); - Buffer.BlockCopy(ciphertext, 0, result, NonceSize, ciphertext.Length); - Buffer.BlockCopy(tag, 0, result, NonceSize + ciphertext.Length, TagSize); - - return result; - } - - /// - /// Decrypts a [nonce + ciphertext + tag] payload produced by Encrypt. - /// Throws CryptographicException if the key is wrong, the data is - /// corrupted, or anyone has tampered with the ciphertext. - /// - public static byte[] Decrypt(byte[] key, byte[] combined) - { - // Split the combined payload back into its three components. - - int ciphertextLength = combined.Length - NonceSize - TagSize; - - byte[] nonce = new byte[NonceSize]; - byte[] ciphertext = new byte[ciphertextLength]; - byte[] tag = new byte[TagSize]; - - Buffer.BlockCopy(combined, 0, nonce, 0, NonceSize); - Buffer.BlockCopy(combined, NonceSize, ciphertext, 0, ciphertextLength); - Buffer.BlockCopy(combined, NonceSize + ciphertextLength, tag, 0, TagSize); - - // Decrypt and verify the authentication tag in one step. - // If the tag doesn't match, AesGcm throws CryptographicException. - - byte[] plaintext = new byte[ciphertextLength]; - - using (AesGcm aes = new(key, TagSize)) - { - aes.Decrypt(nonce, ciphertext, tag, plaintext); - } - - return plaintext; - } - - /// - /// Generates a cryptographically random 256-bit key suitable for AES-256-GCM. - /// Uses the operating system's CSPRNG (e.g., /dev/urandom on Linux). - /// - public static byte[] GenerateKey() - { - byte[] key = new byte[KeySize]; - RandomNumberGenerator.Fill(key); - return key; - } -} diff --git a/src/EntKube.Secrets/Crypto/EnvelopeEncryption.cs b/src/EntKube.Secrets/Crypto/EnvelopeEncryption.cs deleted file mode 100644 index be7da02..0000000 --- a/src/EntKube.Secrets/Crypto/EnvelopeEncryption.cs +++ /dev/null @@ -1,120 +0,0 @@ -namespace EntKube.Secrets.Crypto; - -/// -/// Envelope encryption: each secret gets its own random Data Encryption Key (DEK). -/// The DEK encrypts the secret, and the Master Encryption Key (MEK) encrypts the DEK. -/// -/// This two-layer approach provides critical advantages: -/// - The MEK never directly touches secret data (limits exposure) -/// - Each secret has a unique DEK (compromising one doesn't expose others) -/// - MEK rotation only requires re-encrypting DEKs, not all secrets -/// - DEK + encrypted data can be stored together; only the MEK needs protection -/// -/// This is the same envelope encryption pattern used by AWS KMS, GCP KMS, -/// HashiCorp Vault, and OpenBao. -/// -public static class EnvelopeEncryption -{ - /// - /// Seals plaintext in an encrypted envelope. Generates a fresh random DEK, - /// encrypts the plaintext with it, then encrypts the DEK with the MEK. - /// Returns both the encrypted DEK and encrypted data as an EncryptedEnvelope. - /// - public static EncryptedEnvelope Seal(byte[] masterKey, byte[] plaintext) - { - // Generate a unique DEK for this secret. Each secret gets its own key - // so that compromise of one DEK doesn't affect other secrets. - - byte[] dek = AesGcmEncryptor.GenerateKey(); - - // Encrypt the actual secret data with the DEK. - - byte[] encryptedData = AesGcmEncryptor.Encrypt(dek, plaintext); - - // Encrypt the DEK with the master key. The DEK itself becomes the - // "payload" that the master key protects. - - byte[] encryptedDek = AesGcmEncryptor.Encrypt(masterKey, dek); - - // Securely clear the plaintext DEK from memory now that it's encrypted. - // This reduces the window where the key exists in unprotected memory. - - CryptographicExtensions.SecureClear(dek); - - return new EncryptedEnvelope(encryptedDek, encryptedData); - } - - /// - /// Opens an encrypted envelope. Decrypts the DEK using the MEK, then - /// uses the DEK to decrypt the actual secret data. - /// Throws CryptographicException if the MEK is wrong or data is tampered. - /// - public static byte[] Open(byte[] masterKey, EncryptedEnvelope envelope) - { - // First, recover the DEK by decrypting it with the master key. - - byte[] dek = AesGcmEncryptor.Decrypt(masterKey, envelope.EncryptedDek); - - // Then use the recovered DEK to decrypt the actual secret data. - - try - { - byte[] plaintext = AesGcmEncryptor.Decrypt(dek, envelope.EncryptedData); - return plaintext; - } - finally - { - // Clear the DEK from memory as soon as we're done with it. - - CryptographicExtensions.SecureClear(dek); - } - } - - /// - /// Re-wraps an envelope from one master key to another. This is used during - /// master key rotation: the DEK is decrypted with the old MEK and re-encrypted - /// with the new MEK. The encrypted data itself is untouched (it's still - /// encrypted with the same DEK). - /// - public static EncryptedEnvelope ReWrap(byte[] oldMasterKey, byte[] newMasterKey, EncryptedEnvelope envelope) - { - // Decrypt the DEK with the old master key. - - byte[] dek = AesGcmEncryptor.Decrypt(oldMasterKey, envelope.EncryptedDek); - - // Re-encrypt the DEK with the new master key. - - try - { - byte[] reEncryptedDek = AesGcmEncryptor.Encrypt(newMasterKey, dek); - return new EncryptedEnvelope(reEncryptedDek, envelope.EncryptedData); - } - finally - { - CryptographicExtensions.SecureClear(dek); - } - } -} - -/// -/// An encrypted envelope containing the encrypted DEK and the encrypted secret data. -/// Both are AES-256-GCM ciphertexts (with embedded nonces and auth tags). -/// These can be safely stored together — without the MEK, neither can be decrypted. -/// -public record EncryptedEnvelope(byte[] EncryptedDek, byte[] EncryptedData); - -/// -/// Cryptographic memory safety helpers. -/// -internal static class CryptographicExtensions -{ - /// - /// Overwrites a byte array with zeros to remove sensitive data from memory. - /// This doesn't guarantee the GC hasn't already copied the array, but it - /// reduces the attack surface for memory-scanning attacks. - /// - public static void SecureClear(byte[] data) - { - System.Security.Cryptography.CryptographicOperations.ZeroMemory(data); - } -} diff --git a/src/EntKube.Secrets/Crypto/ShamirSecretSharing.cs b/src/EntKube.Secrets/Crypto/ShamirSecretSharing.cs deleted file mode 100644 index b532d74..0000000 --- a/src/EntKube.Secrets/Crypto/ShamirSecretSharing.cs +++ /dev/null @@ -1,329 +0,0 @@ -using System.Security.Cryptography; - -namespace EntKube.Secrets.Crypto; - -/// -/// Shamir's Secret Sharing over GF(257). Splits a byte array into N shares -/// such that any M shares can reconstruct the original, but fewer than M -/// shares reveal absolutely nothing about the secret. -/// -/// We work in GF(257) — the smallest prime field that contains all 256 byte -/// values. Each byte of the secret is treated independently: for each byte, -/// a random polynomial of degree (threshold - 1) is generated with the byte -/// as the constant term, and N points on that polynomial become the shares. -/// -/// Reconstruction uses Lagrange interpolation to recover the constant term -/// (the original byte) from any M points on the polynomial. -/// -/// This is the same mathematical scheme used by HashiCorp Vault and OpenBao -/// for their unseal key mechanism. -/// -public static class ShamirSecretSharing -{ - // GF(257) — the smallest prime that can represent all 256 byte values (0-255). - // Arithmetic modulo 257 ensures no information leakage through modular reduction. - - private const int Prime = 257; - - /// - /// Splits a secret into N shares with a threshold of M. - /// Any M shares can reconstruct the secret; fewer than M reveal nothing. - /// - public static List Split(byte[] secret, int totalShares, int threshold) - { - // Validate inputs — threshold must make mathematical sense. - - if (threshold < 1) - { - throw new ArgumentException("Threshold must be at least 1.", nameof(threshold)); - } - - if (totalShares < threshold) - { - throw new ArgumentException("Total shares must be >= threshold.", nameof(totalShares)); - } - - // Each share will hold one byte per byte of the secret. - // Share indices are 1-based (x = 1, 2, ..., N) because x = 0 is the secret itself. - - List shares = new(); - - for (int i = 0; i < totalShares; i++) - { - shares.Add(new ShamirShare(Index: i + 1, Data: new byte[secret.Length])); - } - - // For each byte of the secret, create a random polynomial of degree (threshold - 1) - // with the secret byte as the constant term, then evaluate at x = 1..N. - - for (int byteIndex = 0; byteIndex < secret.Length; byteIndex++) - { - // Build random coefficients for the polynomial. - // coefficients[0] = secret byte (the value we're protecting). - // coefficients[1..threshold-1] = random values in [0, Prime-1). - - int[] coefficients = new int[threshold]; - coefficients[0] = secret[byteIndex]; - - for (int c = 1; c < threshold; c++) - { - coefficients[c] = RandomNumberGenerator.GetInt32(Prime); - } - - // Evaluate the polynomial at each share's x-coordinate. - - for (int shareIndex = 0; shareIndex < totalShares; shareIndex++) - { - int x = shareIndex + 1; - int y = EvaluatePolynomial(coefficients, x); - shares[shareIndex].Data[byteIndex] = (byte)(y % 256); - - // Store the full GF(257) value — we need the extra bit. - // We use a separate overflow tracking approach: store the - // value mod 256 in Data and the full value is reconstructed - // during Combine via Lagrange interpolation in GF(257). - } - } - - // We need to store the full GF(257) evaluation results, not truncated to byte. - // Refactor: use int arrays in shares instead. - - List fullShares = new(); - - for (int i = 0; i < totalShares; i++) - { - int[] fullData = new int[secret.Length]; - - for (int byteIndex = 0; byteIndex < secret.Length; byteIndex++) - { - int[] coefficients = new int[threshold]; - coefficients[0] = secret[byteIndex]; - - // We need deterministic coefficients for each byte — re-derive them. - // This won't work with random coefficients. Let's restructure. - } - - fullShares.Add(new ShamirShare(Index: i + 1, Data: new byte[secret.Length], FullValues: fullData)); - } - - // Restructure: compute everything in one pass, store full int values. - - return SplitInternal(secret, totalShares, threshold); - } - - private static List SplitInternal(byte[] secret, int totalShares, int threshold) - { - // Allocate share storage — each share stores the full GF(257) values. - - int[][] shareValues = new int[totalShares][]; - - for (int i = 0; i < totalShares; i++) - { - shareValues[i] = new int[secret.Length]; - } - - // For each byte of the secret, generate a random polynomial and evaluate it. - - for (int byteIndex = 0; byteIndex < secret.Length; byteIndex++) - { - int[] coefficients = new int[threshold]; - coefficients[0] = secret[byteIndex]; - - for (int c = 1; c < threshold; c++) - { - coefficients[c] = RandomNumberGenerator.GetInt32(Prime); - } - - for (int shareIndex = 0; shareIndex < totalShares; shareIndex++) - { - int x = shareIndex + 1; - shareValues[shareIndex][byteIndex] = EvaluatePolynomial(coefficients, x); - } - } - - // Package into ShamirShare records. - - List shares = new(); - - for (int i = 0; i < totalShares; i++) - { - shares.Add(new ShamirShare(Index: i + 1, Data: Array.Empty(), FullValues: shareValues[i])); - } - - return shares; - } - - /// - /// Reconstructs the secret from M shares using Lagrange interpolation in GF(257). - /// The shares must have been produced by Split with the same threshold. - /// - public static byte[] Combine(List shares, int threshold) - { - if (shares.Count == 0) - { - throw new ArgumentException("At least one share is required.", nameof(shares)); - } - - // Use exactly 'threshold' shares for interpolation. - - List selected = shares.Take(threshold).ToList(); - int secretLength = selected[0].FullValues!.Length; - byte[] secret = new byte[secretLength]; - - // For each byte position, use Lagrange interpolation to recover - // the polynomial's constant term (the original secret byte). - - for (int byteIndex = 0; byteIndex < secretLength; byteIndex++) - { - int reconstructed = LagrangeInterpolateAtZero(selected, byteIndex); - secret[byteIndex] = (byte)reconstructed; - } - - return secret; - } - - /// - /// Evaluates a polynomial at x in GF(257) using Horner's method. - /// polynomial(x) = coefficients[0] + coefficients[1]*x + coefficients[2]*x^2 + ... - /// - private static int EvaluatePolynomial(int[] coefficients, int x) - { - // Horner's method evaluates from highest degree down: - // result = c[n]*x + c[n-1], then result*x + c[n-2], etc. - - int result = 0; - - for (int i = coefficients.Length - 1; i >= 0; i--) - { - result = Mod(result * x + coefficients[i], Prime); - } - - return result; - } - - /// - /// Uses Lagrange interpolation to find the value of the polynomial at x = 0 - /// (which is the original secret byte). Works in GF(257). - /// - private static int LagrangeInterpolateAtZero(List shares, int byteIndex) - { - int result = 0; - - for (int i = 0; i < shares.Count; i++) - { - int xi = shares[i].Index; - int yi = shares[i].FullValues![byteIndex]; - - // Compute the Lagrange basis polynomial L_i(0). - // L_i(0) = ∏(j≠i) (0 - xj) / (xi - xj) - // = ∏(j≠i) (-xj) / (xi - xj) - - int numerator = 1; - int denominator = 1; - - for (int j = 0; j < shares.Count; j++) - { - if (i == j) - { - continue; - } - - int xj = shares[j].Index; - numerator = Mod(numerator * Mod(-xj, Prime), Prime); - denominator = Mod(denominator * Mod(xi - xj, Prime), Prime); - } - - // L_i(0) = numerator * modular_inverse(denominator) - - int lagrangeBasis = Mod(numerator * ModInverse(denominator, Prime), Prime); - result = Mod(result + yi * lagrangeBasis, Prime); - } - - return result; - } - - /// - /// Computes the modular multiplicative inverse using Fermat's little theorem: - /// a^(-1) ≡ a^(p-2) mod p (only works when p is prime). - /// - private static int ModInverse(int a, int p) - { - return ModPow(Mod(a, p), p - 2, p); - } - - /// - /// Modular exponentiation by squaring: base^exp mod mod. - /// - private static int ModPow(int baseVal, int exp, int mod) - { - long result = 1; - long b = Mod(baseVal, mod); - - while (exp > 0) - { - if ((exp & 1) == 1) - { - result = (result * b) % mod; - } - - b = (b * b) % mod; - exp >>= 1; - } - - return (int)result; - } - - /// - /// True modulo that always returns a non-negative result (unlike C# %). - /// - private static int Mod(int value, int modulus) - { - int result = value % modulus; - return result < 0 ? result + modulus : result; - } -} - -/// -/// A single share from Shamir's Secret Sharing. Contains the share index -/// (the x-coordinate used in polynomial evaluation) and the share values -/// (the y-coordinates, one per byte of the original secret). -/// -public record ShamirShare(int Index, byte[] Data, int[]? FullValues = null) -{ - /// - /// Serializes this share to a base64 string for distribution to key holders. - /// Format: [4-byte index][4-byte length][values as 2-byte little-endian ints] - /// - public string ToBase64() - { - int[] values = FullValues ?? Array.Empty(); - byte[] buffer = new byte[4 + 4 + values.Length * 2]; - BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), Index); - BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), values.Length); - - for (int i = 0; i < values.Length; i++) - { - BitConverter.TryWriteBytes(buffer.AsSpan(8 + i * 2, 2), (short)values[i]); - } - - return Convert.ToBase64String(buffer); - } - - /// - /// Deserializes a share from its base64 representation. - /// - public static ShamirShare FromBase64(string base64) - { - byte[] buffer = Convert.FromBase64String(base64); - int index = BitConverter.ToInt32(buffer, 0); - int length = BitConverter.ToInt32(buffer, 4); - int[] values = new int[length]; - - for (int i = 0; i < length; i++) - { - values[i] = BitConverter.ToInt16(buffer, 8 + i * 2); - } - - return new ShamirShare(Index: index, Data: Array.Empty(), FullValues: values); - } -} diff --git a/src/EntKube.Secrets/Dockerfile b/src/EntKube.Secrets/Dockerfile deleted file mode 100644 index ae4be85..0000000 --- a/src/EntKube.Secrets/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 5040 - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["src/EntKube.Secrets/EntKube.Secrets.csproj", "src/EntKube.Secrets/"] -COPY ["src/EntKube.SharedKernel/EntKube.SharedKernel.csproj", "src/EntKube.SharedKernel/"] -RUN dotnet restore "src/EntKube.Secrets/EntKube.Secrets.csproj" -COPY . . -WORKDIR "/src/src/EntKube.Secrets" -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://+:5040 -ENTRYPOINT ["dotnet", "EntKube.Secrets.dll"] diff --git a/src/EntKube.Secrets/Domain/IVaultRepository.cs b/src/EntKube.Secrets/Domain/IVaultRepository.cs deleted file mode 100644 index cb856f5..0000000 --- a/src/EntKube.Secrets/Domain/IVaultRepository.cs +++ /dev/null @@ -1,97 +0,0 @@ -namespace EntKube.Secrets.Domain; - -/// -/// Defines how the vault service persists its encrypted data. Every secret, -/// token, and the master key itself are stored through this interface. -/// -/// In Kubernetes, all pods of the secrets service share the same repository -/// (backed by PostgreSQL via CNPG). This makes the service stateless except -/// for the in-memory master keys — any pod can serve any request as long as -/// it has auto-unsealed by reading the encrypted MEKs from the repository. -/// -/// Multi-tenant: Each tenant has their own VaultState (encrypted MEK), -/// providing cryptographic isolation between tenants. -/// -public interface IVaultRepository -{ - // ─── Vault State (per-tenant) ──────────────────────────────────────── - - Task GetStateAsync(Guid tenantId, CancellationToken ct = default); - - Task> GetAllStatesAsync(CancellationToken ct = default); - - Task SaveStateAsync(VaultState state, CancellationToken ct = default); - - // ─── Encrypted Secrets ─────────────────────────────────────────────── - - Task GetSecretAsync(string path, CancellationToken ct = default); - - Task SaveSecretAsync(PersistedSecret secret, CancellationToken ct = default); - - Task> ListSecretPathsAsync(string prefix, bool excludeDeleted = true, CancellationToken ct = default); - - // ─── Service Tokens ────────────────────────────────────────────────── - - Task GetTokenByHashAsync(string tokenHash, CancellationToken ct = default); - - Task SaveTokenAsync(PersistedServiceToken token, CancellationToken ct = default); - - Task> GetActiveTokensAsync(Guid tenantId, CancellationToken ct = default); - - // ─── Audit Log ─────────────────────────────────────────────────────── - - Task AppendAuditEntryAsync(AuditEntry entry, CancellationToken ct = default); - - Task> GetAuditEntriesAsync(CancellationToken ct = default); -} - -/// -/// Persisted vault metadata per tenant. Contains the encrypted master key -/// (wrapped by the shared KEK from a Kubernetes Secret) and a verifier. -/// -public class VaultState -{ - public required Guid TenantId { get; init; } - public required byte[] EncryptedMasterKey { get; init; } - public required byte[] MasterKeyVerifier { get; init; } - public required int ShamirThreshold { get; init; } -} - -/// -/// A secret stored at a path, with versioned encrypted values. -/// Each version contains an independently-encrypted envelope (DEK + ciphertext). -/// The TenantId links the secret to its owning tenant's vault for decryption. -/// -public class PersistedSecret -{ - public required string Path { get; init; } - public Guid TenantId { get; set; } - public SecretScope? Scope { get; set; } - public bool IsDeleted { get; set; } - public List Versions { get; init; } = new(); -} - -/// -/// A single version of an encrypted secret. -/// -public class PersistedSecretVersion -{ - public required int VersionNumber { get; init; } - public required byte[] EncryptedDek { get; init; } - public required byte[] EncryptedData { get; init; } - public required DateTimeOffset CreatedAt { get; init; } -} - -/// -/// A service token persisted with its SHA-256 hash, tenant scope, policies, -/// and revocation state. -/// -public class PersistedServiceToken -{ - public required string TokenHash { get; init; } - public Guid TenantId { get; set; } - public required string Name { get; init; } - public required List Policies { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public bool IsRevoked { get; set; } -} diff --git a/src/EntKube.Secrets/Domain/SecretScope.cs b/src/EntKube.Secrets/Domain/SecretScope.cs deleted file mode 100644 index 958816d..0000000 --- a/src/EntKube.Secrets/Domain/SecretScope.cs +++ /dev/null @@ -1,98 +0,0 @@ -namespace EntKube.Secrets.Domain; - -/// -/// Defines the access scope for a secret. Secrets are organized in a hierarchical -/// structure mirroring the platform's multi-tenant architecture. Two primary -/// scope patterns exist: -/// -/// Infrastructure secrets: Tenant → Environment → Cluster -/// Application secrets: Tenant → Customer → App → Environment -/// -/// Each level narrows the access. A tenant-scoped secret is visible to all -/// environments and customers within that tenant. A cluster-scoped secret is -/// only accessible to that specific cluster in that specific environment. -/// An app-scoped secret is only accessible to that specific application in -/// that specific customer's namespace in that specific environment. -/// -/// The scope is used to: -/// 1. Build the storage path (hierarchical prefix for the secret) -/// 2. Determine access control (service tokens are granted per-scope) -/// 3. Enable filtering (list all secrets for a customer in prod) -/// -public record SecretScope( - Guid TenantId, - Guid? EnvironmentId, - Guid? ClusterId, - Guid? CustomerId, - string? AppName) -{ - /// - /// Creates an infrastructure-scoped secret (Tenant + Environment + Cluster). - /// Used for secrets tied to cluster infrastructure — kubeconfig credentials, - /// cloud provider tokens, DNS credentials, etc. - /// - public static SecretScope ForCluster(Guid tenantId, Guid environmentId, Guid clusterId) - { - return new SecretScope(tenantId, environmentId, clusterId, null, null); - } - - /// - /// Creates an application-scoped secret (Tenant + Customer + App + Environment). - /// Used for secrets tied to a specific application deployment — database - /// connection strings, API keys, third-party service credentials, etc. - /// - public static SecretScope ForApp(Guid tenantId, Guid customerId, string appName, Guid environmentId) - { - return new SecretScope(tenantId, environmentId, null, customerId, appName); - } - - /// - /// Builds the storage path for a secret key within this scope. - /// The path mirrors the hierarchy: - /// Infrastructure: tenants/{id}/environments/{id}/clusters/{id}/secrets/{key} - /// Application: tenants/{id}/environments/{id}/customers/{id}/apps/{name}/secrets/{key} - /// - /// Only the levels that are set are included — a tenant-only scope produces - /// "tenants/{id}/secrets/{key}", while a fully-scoped secret includes all segments. - /// - public string BuildPath(string secretKey) - { - // Start with the tenant — every secret must belong to a tenant. - - string path = $"tenants/{TenantId}"; - - // If scoped to an environment, add that segment. - - if (EnvironmentId.HasValue) - { - path += $"/environments/{EnvironmentId.Value}"; - } - - // If scoped to a cluster (infrastructure secrets), add that segment. - - if (ClusterId.HasValue) - { - path += $"/clusters/{ClusterId.Value}"; - } - - // If scoped to a customer, add that segment. - - if (CustomerId.HasValue) - { - path += $"/customers/{CustomerId.Value}"; - } - - // If scoped to a specific app within the customer, add that segment. - - if (!string.IsNullOrEmpty(AppName)) - { - path += $"/apps/{AppName}"; - } - - // The actual secret key comes last. - - path += $"/secrets/{secretKey}"; - - return path; - } -} diff --git a/src/EntKube.Secrets/Domain/ServiceToken.cs b/src/EntKube.Secrets/Domain/ServiceToken.cs deleted file mode 100644 index a5a093a..0000000 --- a/src/EntKube.Secrets/Domain/ServiceToken.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace EntKube.Secrets.Domain; - -/// -/// Defines the operations a service token can be authorized to perform. -/// Uses flags so a single policy entry can grant multiple operations. -/// -[Flags] -public enum AccessOperation -{ - None = 0, - Read = 1, - Write = 2, - Delete = 4, - List = 8 -} - -/// -/// A policy rule that grants specific operations on secrets matching a path prefix. -/// For example, ("clusters/", Read | Write) means the token can read and write -/// any secret whose path starts with "clusters/". -/// -public record AccessPolicy(string PathPrefix, AccessOperation AllowedOperations); - -/// -/// Result of creating a service token. Contains the plaintext token (shown -/// only once — it's hashed before storage) and its metadata. -/// -public record ServiceTokenResult(string Token, string Name, List Policies); - -/// -/// Result of validating a service token. Reports whether the token is valid -/// and, if so, the associated identity and policies. -/// -public record TokenValidationResult(bool IsValid, string? Name = null, List? Policies = null); - -/// -/// Metadata about a service token (returned by ListTokens). Never includes -/// the actual token value — that was only shown once at creation time. -/// -public record ServiceTokenInfo(string Name, List Policies, DateTimeOffset CreatedAt); diff --git a/src/EntKube.Secrets/Domain/Vault.cs b/src/EntKube.Secrets/Domain/Vault.cs deleted file mode 100644 index afb4b66..0000000 --- a/src/EntKube.Secrets/Domain/Vault.cs +++ /dev/null @@ -1,674 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using EntKube.Secrets.Crypto; - -namespace EntKube.Secrets.Domain; - -/// -/// The Vault is the aggregate root of the secrets manager. It manages per-tenant -/// encryption contexts — each tenant gets their own master encryption key (MEK), -/// providing cryptographic isolation between tenants. -/// -/// Designed for Kubernetes multi-instance deployment: -/// - All encrypted data lives in a shared repository (PostgreSQL in production) -/// - Each tenant's MEK is encrypted by a shared KEK (Key Encryption Key) from a K8s Secret -/// - On pod startup, the vault auto-unseals ALL tenant MEKs using the KEK -/// - Any number of pods can serve requests concurrently -/// -/// Key hierarchy (per tenant): -/// KEK (from K8s Secret, shared) → wraps MEK (per tenant, in repository) → wraps DEKs (per secret) -/// -/// Unseal modes: -/// 1. Auto-unseal (normal): KEK decrypts all tenant MEKs from repository -/// 2. Manual unseal (DR): Shamir shares reconstruct a specific tenant's MEK -/// -/// Shamir shares are generated during tenant vault initialization as a disaster -/// recovery backup — they are NOT needed for daily operations. -/// -public class Vault -{ - private const string VerifierPlaintext = "entkube-vault-master-key-verifier"; - - private readonly IVaultRepository repository; - - // Per-tenant master encryption keys — only present in memory when unsealed. - // Each tenant has their own MEK for cryptographic isolation. - - private readonly Dictionary masterKeys = new(); - - // Per-tenant initialization state. - - private readonly HashSet initializedTenants = new(); - - // Per-tenant Shamir threshold (loaded from repository during unseal). - - private readonly Dictionary shamirThresholds = new(); - - // Per-tenant collected Shamir shares during manual (DR) unseal. - - private readonly Dictionary> collectedShares = new(); - - /// - /// Whether any tenant vault has been initialized in this instance. - /// - public bool IsInitialized => initializedTenants.Count > 0; - - /// - /// Whether all known tenant vaults are sealed (no MEKs in memory). - /// Returns true when there are no unsealed tenants at all. - /// - public bool IsSealed => masterKeys.Count == 0; - - /// - /// Checks whether a specific tenant's vault is initialized. - /// - public bool IsTenantInitialized(Guid tenantId) => initializedTenants.Contains(tenantId); - - /// - /// Checks whether a specific tenant's vault is sealed. - /// - public bool IsTenantSealed(Guid tenantId) => !masterKeys.ContainsKey(tenantId); - - public Vault(IVaultRepository repository) - { - this.repository = repository; - } - - // ─── Lifecycle ─────────────────────────────────────────────────────── - - /// - /// Initializes a tenant's vault for the first time. Generates a master encryption - /// key (MEK) for this tenant, encrypts it with the KEK, and persists the - /// encrypted MEK to the repository. - /// - /// Also generates Shamir shares of the MEK for disaster recovery — if the - /// KEK is ever lost, these shares can reconstruct this tenant's MEK directly. - /// - /// After initialization, the tenant's vault is immediately unsealed and ready. - /// - public async Task InitializeAsync( - Guid tenantId, byte[] kek, int totalShares, int threshold, CancellationToken ct = default) - { - // Check if this tenant already has a vault state — another pod - // may have initialized while we were starting up. - - VaultState? existing = await repository.GetStateAsync(tenantId, ct); - - if (existing is not null) - { - throw new InvalidOperationException( - "Vault is already initialized. Re-initialization would destroy all existing secrets."); - } - - // Generate the master encryption key for this tenant — this is the - // most sensitive key for this tenant's secrets. - - byte[] mek = AesGcmEncryptor.GenerateKey(); - - // Create a verifier so we can confirm the MEK is correct after unseal. - - byte[] verifierCiphertext = AesGcmEncryptor.Encrypt( - mek, Encoding.UTF8.GetBytes(VerifierPlaintext)); - - // Encrypt the MEK with the KEK for persistent storage. - - byte[] encryptedMek = AesGcmEncryptor.Encrypt(kek, mek); - - // Persist the encrypted MEK and verifier. - - VaultState state = new() - { - TenantId = tenantId, - EncryptedMasterKey = encryptedMek, - MasterKeyVerifier = verifierCiphertext, - ShamirThreshold = threshold - }; - - await repository.SaveStateAsync(state, ct); - - // Generate Shamir shares for disaster recovery. - - List shares = ShamirSecretSharing.Split(mek, totalShares, threshold); - shamirThresholds[tenantId] = threshold; - initializedTenants.Add(tenantId); - masterKeys[tenantId] = mek; - - await AppendAuditAsync("initialize", $"tenants/{tenantId}/vault", - $"Tenant vault initialized with {totalShares} shares, threshold {threshold}", ct); - - return new InitializationResult(shares, threshold); - } - - /// - /// Auto-unseals all tenant vaults using the KEK from a Kubernetes Secret. - /// This is the normal startup path — called once when each pod starts. - /// - /// Reads all encrypted tenant MEKs from the shared repository, decrypts them - /// with the KEK, verifies correctness, and holds the plaintext MEKs in memory. - /// - public async Task AutoUnsealAsync(byte[] kek, CancellationToken ct = default) - { - // Load all tenant vault states from the repository. - - List states = await repository.GetAllStatesAsync(ct); - - if (states.Count == 0) - { - throw new InvalidOperationException( - "Vault is not initialized. Call InitializeAsync first."); - } - - // Decrypt each tenant's MEK using the shared KEK. - - foreach (VaultState state in states) - { - byte[] decryptedMek; - - try - { - decryptedMek = AesGcmEncryptor.Decrypt(kek, state.EncryptedMasterKey); - } - catch (CryptographicException) - { - throw new InvalidOperationException( - "Auto-unseal failed: KEK cannot decrypt the stored master key. " + - "Ensure the correct Kubernetes Secret is mounted."); - } - - // Verify the decrypted MEK is correct. - - if (!VerifyMasterKey(decryptedMek, state.MasterKeyVerifier)) - { - CryptographicExtensions.SecureClear(decryptedMek); - - throw new InvalidOperationException( - "Auto-unseal failed: decrypted master key does not match verifier."); - } - - masterKeys[state.TenantId] = decryptedMek; - shamirThresholds[state.TenantId] = state.ShamirThreshold; - initializedTenants.Add(state.TenantId); - } - - await AppendAuditAsync("auto-unseal", "vault", - $"Vault auto-unsealed {states.Count} tenant(s)", ct); - } - - /// - /// Seals a specific tenant's vault by clearing its MEK from memory. - /// Other tenants remain unsealed. - /// - public async Task SealAsync(Guid tenantId, CancellationToken ct = default) - { - if (masterKeys.TryGetValue(tenantId, out byte[]? key)) - { - CryptographicExtensions.SecureClear(key); - masterKeys.Remove(tenantId); - } - - if (collectedShares.ContainsKey(tenantId)) - { - collectedShares.Remove(tenantId); - } - - await AppendAuditAsync("seal", $"tenants/{tenantId}/vault", - "Tenant vault sealed", ct); - } - - /// - /// Seals ALL tenant vaults on this pod. - /// - public async Task SealAsync(CancellationToken ct = default) - { - foreach (KeyValuePair kvp in masterKeys) - { - CryptographicExtensions.SecureClear(kvp.Value); - } - - masterKeys.Clear(); - collectedShares.Clear(); - - await AppendAuditAsync("seal", "vault", "All tenant vaults sealed", ct); - } - - /// - /// Provides one Shamir share for manual (disaster recovery) unseal of a - /// specific tenant's vault. - /// - public async Task ProvideUnsealShareAsync( - Guid tenantId, ShamirShare share, CancellationToken ct = default) - { - // If we haven't loaded this tenant's state yet, check the repository. - - if (!initializedTenants.Contains(tenantId)) - { - VaultState? state = await repository.GetStateAsync(tenantId, ct); - - if (state is null) - { - throw new InvalidOperationException( - "Vault must be initialized before unsealing."); - } - - initializedTenants.Add(tenantId); - shamirThresholds[tenantId] = state.ShamirThreshold; - } - - // Already unsealed — nothing to do. - - if (!IsTenantSealed(tenantId)) - { - int threshold = shamirThresholds[tenantId]; - - return new UnsealResult( - IsUnsealed: true, - SharesProvided: threshold, - SharesRequired: threshold); - } - - // Add this share to the collected set. - - if (!collectedShares.ContainsKey(tenantId)) - { - collectedShares[tenantId] = new List(); - } - - if (!collectedShares[tenantId].Any(s => s.Index == share.Index)) - { - collectedShares[tenantId].Add(share); - } - - int requiredThreshold = shamirThresholds[tenantId]; - - // Not enough shares yet — report progress. - - if (collectedShares[tenantId].Count < requiredThreshold) - { - return new UnsealResult( - IsUnsealed: false, - SharesProvided: collectedShares[tenantId].Count, - SharesRequired: requiredThreshold); - } - - // We have enough shares — reconstruct the master key. - - byte[] reconstructedKey = ShamirSecretSharing.Combine( - collectedShares[tenantId], requiredThreshold); - - // Verify the reconstructed key. - - VaultState? vaultState = await repository.GetStateAsync(tenantId, ct); - - if (!VerifyMasterKey(reconstructedKey, vaultState!.MasterKeyVerifier)) - { - CryptographicExtensions.SecureClear(reconstructedKey); - collectedShares[tenantId].Clear(); - - throw new InvalidOperationException( - "Unseal failed: reconstructed key does not match. Invalid shares provided."); - } - - // Success — hold the MEK in memory. - - masterKeys[tenantId] = reconstructedKey; - collectedShares.Remove(tenantId); - - await AppendAuditAsync("manual-unseal", $"tenants/{tenantId}/vault", - "Tenant vault unsealed via Shamir shares", ct); - - return new UnsealResult( - IsUnsealed: true, - SharesProvided: requiredThreshold, - SharesRequired: requiredThreshold); - } - - /// - /// Re-wraps a tenant's master key with a new KEK. Used during KEK rotation. - /// - public async Task ReWrapMasterKeyAsync( - Guid tenantId, byte[] newKek, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - VaultState? state = await repository.GetStateAsync(tenantId, ct); - - if (state is null) - { - throw new InvalidOperationException("Vault is not initialized."); - } - - byte[] newEncryptedMek = AesGcmEncryptor.Encrypt(newKek, masterKeys[tenantId]); - - VaultState newState = new() - { - TenantId = tenantId, - EncryptedMasterKey = newEncryptedMek, - MasterKeyVerifier = state.MasterKeyVerifier, - ShamirThreshold = state.ShamirThreshold - }; - - await repository.SaveStateAsync(newState, ct); - - await AppendAuditAsync("rewrap", $"tenants/{tenantId}/vault", - "Master key re-wrapped with new KEK", ct); - } - - // ─── Secret Operations ─────────────────────────────────────────────── - - /// - /// Stores a secret at the given path within a tenant's vault. If the path - /// already exists, a new version is created. The secret value is encrypted - /// using this tenant's MEK via envelope encryption. - /// - public async Task PutSecretAsync( - Guid tenantId, string path, string value, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - byte[] plaintext = Encoding.UTF8.GetBytes(value); - EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKeys[tenantId], plaintext); - - PersistedSecret? existing = await repository.GetSecretAsync(path, ct); - - if (existing is not null) - { - existing.IsDeleted = false; - existing.Versions.Add(new PersistedSecretVersion - { - VersionNumber = existing.Versions.Count + 1, - EncryptedDek = envelope.EncryptedDek, - EncryptedData = envelope.EncryptedData, - CreatedAt = DateTimeOffset.UtcNow - }); - - await repository.SaveSecretAsync(existing, ct); - } - else - { - PersistedSecret newSecret = new() - { - Path = path, - TenantId = tenantId, - IsDeleted = false, - Versions = new List - { - new() - { - VersionNumber = 1, - EncryptedDek = envelope.EncryptedDek, - EncryptedData = envelope.EncryptedData, - CreatedAt = DateTimeOffset.UtcNow - } - } - }; - - await repository.SaveSecretAsync(newSecret, ct); - } - - await AppendAuditAsync("put", path, "Secret written", ct); - } - - /// - /// Retrieves the latest version of a secret at the given path. - /// Returns null if the path doesn't exist or has been deleted. - /// - public async Task GetSecretAsync( - Guid tenantId, string path, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - await AppendAuditAsync("get", path, "Secret read", ct); - - PersistedSecret? secret = await repository.GetSecretAsync(path, ct); - - if (secret is null || secret.IsDeleted || secret.Versions.Count == 0) - { - return null; - } - - PersistedSecretVersion latest = secret.Versions[^1]; - EncryptedEnvelope envelope = new(latest.EncryptedDek, latest.EncryptedData); - byte[] plaintext = EnvelopeEncryption.Open(masterKeys[tenantId], envelope); - - return Encoding.UTF8.GetString(plaintext); - } - - /// - /// Retrieves a specific version of a secret by version number (1-based). - /// - public async Task GetSecretVersionAsync( - Guid tenantId, string path, int version, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - PersistedSecret? secret = await repository.GetSecretAsync(path, ct); - - if (secret is null || version < 1 || version > secret.Versions.Count) - { - return null; - } - - PersistedSecretVersion v = secret.Versions[version - 1]; - EncryptedEnvelope envelope = new(v.EncryptedDek, v.EncryptedData); - byte[] plaintext = EnvelopeEncryption.Open(masterKeys[tenantId], envelope); - - return Encoding.UTF8.GetString(plaintext); - } - - /// - /// Returns the number of versions stored for a secret at the given path. - /// - public async Task GetSecretVersionCountAsync( - Guid tenantId, string path, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - PersistedSecret? secret = await repository.GetSecretAsync(path, ct); - return secret?.Versions.Count ?? 0; - } - - /// - /// Soft-deletes a secret. - /// - public async Task DeleteSecretAsync( - Guid tenantId, string path, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - PersistedSecret? secret = await repository.GetSecretAsync(path, ct); - - if (secret is not null) - { - secret.IsDeleted = true; - await repository.SaveSecretAsync(secret, ct); - } - - await AppendAuditAsync("delete", path, "Secret deleted", ct); - } - - /// - /// Lists all non-deleted secret paths under the given prefix. - /// - public async Task> ListSecretsAsync( - Guid tenantId, string prefix, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - await AppendAuditAsync("list", prefix, "Secrets listed", ct); - - return await repository.ListSecretPathsAsync(prefix, excludeDeleted: true, ct); - } - - // ─── Audit ─────────────────────────────────────────────────────────── - - public async Task> GetAuditLogAsync(CancellationToken ct = default) - { - return await repository.GetAuditEntriesAsync(ct); - } - - // ─── Service Tokens ────────────────────────────────────────────────── - - /// - /// Creates a service token for machine-to-machine authentication. - /// Tokens are scoped to a tenant and have path-based access policies. - /// - public async Task CreateServiceTokenAsync( - Guid tenantId, string name, List policies, CancellationToken ct = default) - { - EnsureUnsealed(tenantId); - - byte[] tokenBytes = new byte[32]; - RandomNumberGenerator.Fill(tokenBytes); - string token = $"evs_{Convert.ToHexStringLower(tokenBytes)}"; - - string tokenHash = HashToken(token); - - PersistedServiceToken persisted = new() - { - TokenHash = tokenHash, - TenantId = tenantId, - Name = name, - Policies = policies, - CreatedAt = DateTimeOffset.UtcNow - }; - - await repository.SaveTokenAsync(persisted, ct); - - await AppendAuditAsync("create-token", $"tenants/{tenantId}/tokens/{name}", - $"Service token created for {name}", ct); - - return new ServiceTokenResult(token, name, policies); - } - - /// - /// Validates a service token by hashing it and looking up the hash. - /// - public async Task ValidateTokenAsync( - string token, CancellationToken ct = default) - { - string tokenHash = HashToken(token); - PersistedServiceToken? stored = await repository.GetTokenByHashAsync(tokenHash, ct); - - if (stored is null || stored.IsRevoked) - { - return new TokenValidationResult(IsValid: false); - } - - return new TokenValidationResult( - IsValid: true, - Name: stored.Name, - Policies: stored.Policies); - } - - /// - /// Checks whether a token has access to perform a specific operation on a path. - /// - public async Task CheckAccessAsync( - string token, string path, AccessOperation operation, CancellationToken ct = default) - { - string tokenHash = HashToken(token); - PersistedServiceToken? stored = await repository.GetTokenByHashAsync(tokenHash, ct); - - if (stored is null || stored.IsRevoked) - { - return false; - } - - return stored.Policies.Any(p => - path.StartsWith(p.PathPrefix, StringComparison.Ordinal) - && p.AllowedOperations.HasFlag(operation)); - } - - /// - /// Revokes a service token. - /// - public async Task RevokeTokenAsync(string token, CancellationToken ct = default) - { - string tokenHash = HashToken(token); - PersistedServiceToken? stored = await repository.GetTokenByHashAsync(tokenHash, ct); - - if (stored is not null) - { - stored.IsRevoked = true; - await repository.SaveTokenAsync(stored, ct); - - await AppendAuditAsync("revoke-token", $"tokens/{stored.Name}", - $"Service token revoked for {stored.Name}", ct); - } - } - - /// - /// Lists metadata about all active service tokens for a tenant. - /// - public async Task> ListTokensAsync( - Guid tenantId, CancellationToken ct = default) - { - List tokens = await repository.GetActiveTokensAsync(tenantId, ct); - - return tokens - .Select(t => new ServiceTokenInfo(t.Name, t.Policies, t.CreatedAt)) - .ToList(); - } - - // ─── Helpers ───────────────────────────────────────────────────────── - - private void EnsureUnsealed(Guid tenantId) - { - if (!masterKeys.ContainsKey(tenantId)) - { - throw new VaultSealedException( - "Vault is sealed. Auto-unseal with KEK or provide Shamir shares."); - } - } - - private static bool VerifyMasterKey(byte[] candidateKey, byte[] verifierCiphertext) - { - try - { - byte[] decrypted = AesGcmEncryptor.Decrypt(candidateKey, verifierCiphertext); - return Encoding.UTF8.GetString(decrypted) == VerifierPlaintext; - } - catch (CryptographicException) - { - return false; - } - } - - private async Task AppendAuditAsync( - string operation, string path, string detail, CancellationToken ct) - { - await repository.AppendAuditEntryAsync( - new AuditEntry(DateTimeOffset.UtcNow, operation, path, detail), ct); - } - - private static string HashToken(string token) - { - byte[] tokenBytes = Encoding.UTF8.GetBytes(token); - byte[] hash = SHA256.HashData(tokenBytes); - return Convert.ToHexStringLower(hash); - } -} - -/// -/// Result of vault initialization. Contains the Shamir shares for disaster -/// recovery and the threshold needed for manual unseal. -/// -public record InitializationResult(List Shares, int Threshold); - -/// -/// Result of providing an unseal share. -/// -public record UnsealResult(bool IsUnsealed, int SharesProvided, int SharesRequired); - -/// -/// An append-only audit log entry recording a vault operation. -/// -public record AuditEntry(DateTimeOffset Timestamp, string Operation, string Path, string Detail); - -/// -/// Thrown when a secret operation is attempted on a sealed vault. -/// -public class VaultSealedException : InvalidOperationException -{ - public VaultSealedException(string message) : base(message) { } -} diff --git a/src/EntKube.Secrets/EntKube.Secrets.csproj b/src/EntKube.Secrets/EntKube.Secrets.csproj deleted file mode 100644 index 85eed18..0000000 --- a/src/EntKube.Secrets/EntKube.Secrets.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - - - - diff --git a/src/EntKube.Secrets/Features/DeleteSecret/DeleteSecretEndpoint.cs b/src/EntKube.Secrets/Features/DeleteSecret/DeleteSecretEndpoint.cs deleted file mode 100644 index 31929bf..0000000 --- a/src/EntKube.Secrets/Features/DeleteSecret/DeleteSecretEndpoint.cs +++ /dev/null @@ -1,60 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.DeleteSecret; - -/// -/// DELETE /api/vault/{tenantId}/secrets/{**path} — Soft-deletes a secret. -/// -public static class DeleteSecretEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapDelete("/api/vault/{tenantId:guid}/secrets/{**path}", Handle); - } - - private static async Task Handle( - Guid tenantId, string path, HttpContext httpContext, Vault vault) - { - IResult? authError = await AuthenticateRequestAsync(httpContext, vault, path, AccessOperation.Delete); - - if (authError is not null) - { - return authError; - } - - try - { - await vault.DeleteSecretAsync(tenantId, path); - - return Results.Ok(new DeleteSecretResponse( - Path: path, - Message: "Secret deleted.")); - } - catch (VaultSealedException) - { - return Results.StatusCode(503); - } - } - - private static async Task AuthenticateRequestAsync( - HttpContext httpContext, Vault vault, string path, AccessOperation operation) - { - string? authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); - - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return Results.Unauthorized(); - } - - string token = authHeader["Bearer ".Length..]; - - if (!await vault.CheckAccessAsync(token, path, operation)) - { - return Results.Forbid(); - } - - return null; - } -} - -public record DeleteSecretResponse(string Path, string Message); diff --git a/src/EntKube.Secrets/Features/GetSecret/GetSecretEndpoint.cs b/src/EntKube.Secrets/Features/GetSecret/GetSecretEndpoint.cs deleted file mode 100644 index 8d6ca99..0000000 --- a/src/EntKube.Secrets/Features/GetSecret/GetSecretEndpoint.cs +++ /dev/null @@ -1,76 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.GetSecret; - -/// -/// GET /api/vault/{tenantId}/secrets/{**path} — Retrieves the latest version of a secret. -/// Use ?version=N query parameter to fetch a specific version. -/// -public static class GetSecretEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/vault/{tenantId:guid}/secrets/{**path}", Handle); - } - - private static async Task Handle( - Guid tenantId, string path, HttpContext httpContext, Vault vault, int? version = null) - { - IResult? authError = await AuthenticateRequestAsync(httpContext, vault, path, AccessOperation.Read); - - if (authError is not null) - { - return authError; - } - - try - { - string? value; - - if (version.HasValue) - { - value = await vault.GetSecretVersionAsync(tenantId, path, version.Value); - } - else - { - value = await vault.GetSecretAsync(tenantId, path); - } - - if (value is null) - { - return Results.NotFound(new { Error = $"Secret not found at path: {path}" }); - } - - return Results.Ok(new GetSecretResponse( - Path: path, - Value: value, - Version: version ?? await vault.GetSecretVersionCountAsync(tenantId, path))); - } - catch (VaultSealedException) - { - return Results.StatusCode(503); - } - } - - private static async Task AuthenticateRequestAsync( - HttpContext httpContext, Vault vault, string path, AccessOperation operation) - { - string? authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); - - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return Results.Unauthorized(); - } - - string token = authHeader["Bearer ".Length..]; - - if (!await vault.CheckAccessAsync(token, path, operation)) - { - return Results.Forbid(); - } - - return null; - } -} - -public record GetSecretResponse(string Path, string Value, int Version); diff --git a/src/EntKube.Secrets/Features/Initialize/InitializeEndpoint.cs b/src/EntKube.Secrets/Features/Initialize/InitializeEndpoint.cs deleted file mode 100644 index 2c2c0df..0000000 --- a/src/EntKube.Secrets/Features/Initialize/InitializeEndpoint.cs +++ /dev/null @@ -1,57 +0,0 @@ -using EntKube.Secrets.Crypto; -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.Initialize; - -/// -/// POST /api/vault/{tenantId}/init — Initializes a tenant's vault by generating -/// a master key, encrypting it with the KEK, and persisting it. Returns Shamir -/// shares for disaster recovery. -/// -public static class InitializeEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/{tenantId:guid}/init", Handle); - } - - private static async Task Handle( - Guid tenantId, InitializeRequest request, Vault vault, IConfiguration configuration) - { - // The KEK comes from the Kubernetes Secret mounted as configuration. - - string? kekHex = configuration["Vault:Kek"]; - - if (string.IsNullOrEmpty(kekHex)) - { - return Results.BadRequest(new { Error = "Vault:Kek configuration is not set. Mount the KEK Kubernetes Secret." }); - } - - int totalShares = request.TotalShares > 0 ? request.TotalShares : 5; - int threshold = request.Threshold > 0 ? request.Threshold : 3; - - try - { - byte[] kek = Convert.FromHexString(kekHex); - InitializationResult result = await vault.InitializeAsync(tenantId, kek, totalShares, threshold); - - List shareStrings = result.Shares - .Select(s => s.ToBase64()) - .ToList(); - - return Results.Ok(new InitializeResponse( - Shares: shareStrings, - Threshold: result.Threshold, - Message: $"Tenant vault initialized. Distribute these {totalShares} shares securely. " + - $"Any {threshold} are needed to unseal. These shares are shown ONCE.")); - } - catch (InvalidOperationException ex) - { - return Results.Conflict(new { Error = ex.Message }); - } - } -} - -public record InitializeRequest(int TotalShares = 5, int Threshold = 3); - -public record InitializeResponse(List Shares, int Threshold, string Message); diff --git a/src/EntKube.Secrets/Features/ListSecrets/ListSecretsEndpoint.cs b/src/EntKube.Secrets/Features/ListSecrets/ListSecretsEndpoint.cs deleted file mode 100644 index 9fb24d9..0000000 --- a/src/EntKube.Secrets/Features/ListSecrets/ListSecretsEndpoint.cs +++ /dev/null @@ -1,62 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.ListSecrets; - -/// -/// GET /api/vault/{tenantId}/secrets?prefix={prefix} — Lists all non-deleted -/// secret paths under the given prefix for a tenant. -/// -public static class ListSecretsEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/vault/{tenantId:guid}/secrets", Handle); - } - - private static async Task Handle( - Guid tenantId, HttpContext httpContext, Vault vault, string prefix = "") - { - IResult? authError = await AuthenticateRequestAsync(httpContext, vault, prefix, AccessOperation.List); - - if (authError is not null) - { - return authError; - } - - try - { - List paths = await vault.ListSecretsAsync(tenantId, prefix); - - return Results.Ok(new ListSecretsResponse( - Prefix: prefix, - Paths: paths, - Count: paths.Count)); - } - catch (VaultSealedException) - { - return Results.StatusCode(503); - } - } - - private static async Task AuthenticateRequestAsync( - HttpContext httpContext, Vault vault, string path, AccessOperation operation) - { - string? authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); - - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return Results.Unauthorized(); - } - - string token = authHeader["Bearer ".Length..]; - - if (!await vault.CheckAccessAsync(token, path, operation)) - { - return Results.Forbid(); - } - - return null; - } -} - -public record ListSecretsResponse(string Prefix, List Paths, int Count); diff --git a/src/EntKube.Secrets/Features/PutSecret/PutSecretEndpoint.cs b/src/EntKube.Secrets/Features/PutSecret/PutSecretEndpoint.cs deleted file mode 100644 index ef8522b..0000000 --- a/src/EntKube.Secrets/Features/PutSecret/PutSecretEndpoint.cs +++ /dev/null @@ -1,68 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.PutSecret; - -/// -/// POST /api/vault/{tenantId}/secrets/{**path} — Stores a secret at the given path -/// within a tenant's vault. If the path already exists, a new version is created. -/// Requires a valid service token with Write permission on the path. -/// -public static class PutSecretEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/{tenantId:guid}/secrets/{**path}", Handle); - } - - private static async Task Handle( - Guid tenantId, string path, PutSecretRequest request, HttpContext httpContext, Vault vault) - { - // Authenticate the request using the service token from the Authorization header. - - IResult? authError = await AuthenticateRequestAsync(httpContext, vault, path, AccessOperation.Write); - - if (authError is not null) - { - return authError; - } - - try - { - await vault.PutSecretAsync(tenantId, path, request.Value); - int versionCount = await vault.GetSecretVersionCountAsync(tenantId, path); - - return Results.Ok(new PutSecretResponse( - Path: path, - Version: versionCount, - Message: "Secret stored successfully.")); - } - catch (VaultSealedException) - { - return Results.StatusCode(503); - } - } - - private static async Task AuthenticateRequestAsync( - HttpContext httpContext, Vault vault, string path, AccessOperation operation) - { - string? authHeader = httpContext.Request.Headers.Authorization.FirstOrDefault(); - - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return Results.Unauthorized(); - } - - string token = authHeader["Bearer ".Length..]; - - if (!await vault.CheckAccessAsync(token, path, operation)) - { - return Results.Forbid(); - } - - return null; - } -} - -public record PutSecretRequest(string Value); - -public record PutSecretResponse(string Path, int Version, string Message); diff --git a/src/EntKube.Secrets/Features/Seal/SealEndpoint.cs b/src/EntKube.Secrets/Features/Seal/SealEndpoint.cs deleted file mode 100644 index da64296..0000000 --- a/src/EntKube.Secrets/Features/Seal/SealEndpoint.cs +++ /dev/null @@ -1,28 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.Seal; - -/// -/// POST /api/vault/seal — Seals ALL tenant vaults on this pod. -/// POST /api/vault/{tenantId}/seal — Seals a specific tenant's vault. -/// -public static class SealEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/seal", HandleAll); - app.MapPost("/api/vault/{tenantId:guid}/seal", HandleTenant); - } - - private static async Task HandleAll(Vault vault) - { - await vault.SealAsync(); - return Results.Ok(new { Message = "All tenant vaults sealed. Master keys cleared from memory." }); - } - - private static async Task HandleTenant(Guid tenantId, Vault vault) - { - await vault.SealAsync(tenantId); - return Results.Ok(new { Message = "Tenant vault sealed. Master key cleared from memory." }); - } -} diff --git a/src/EntKube.Secrets/Features/Status/StatusEndpoint.cs b/src/EntKube.Secrets/Features/Status/StatusEndpoint.cs deleted file mode 100644 index ce394b5..0000000 --- a/src/EntKube.Secrets/Features/Status/StatusEndpoint.cs +++ /dev/null @@ -1,32 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.Status; - -/// -/// GET /api/vault/status — Returns the overall vault status. -/// GET /api/vault/{tenantId}/status — Returns a specific tenant's vault status. -/// -public static class StatusEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/vault/status", HandleGlobal); - app.MapGet("/api/vault/{tenantId:guid}/status", HandleTenant); - } - - private static IResult HandleGlobal(Vault vault) - { - return Results.Ok(new StatusResponse( - IsInitialized: vault.IsInitialized, - IsSealed: vault.IsSealed)); - } - - private static IResult HandleTenant(Guid tenantId, Vault vault) - { - return Results.Ok(new StatusResponse( - IsInitialized: vault.IsTenantInitialized(tenantId), - IsSealed: vault.IsTenantSealed(tenantId))); - } -} - -public record StatusResponse(bool IsInitialized, bool IsSealed); diff --git a/src/EntKube.Secrets/Features/Tokens/TokenEndpoints.cs b/src/EntKube.Secrets/Features/Tokens/TokenEndpoints.cs deleted file mode 100644 index f45ad97..0000000 --- a/src/EntKube.Secrets/Features/Tokens/TokenEndpoints.cs +++ /dev/null @@ -1,119 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.Tokens; - -/// -/// POST /api/vault/{tenantId}/tokens — Creates a new service token with the -/// specified name and access policies, scoped to a tenant. -/// -public static class CreateTokenEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/{tenantId:guid}/tokens", Handle); - } - - private static async Task Handle( - Guid tenantId, CreateTokenRequest request, Vault vault) - { - try - { - List policies = request.Policies - .Select(p => new AccessPolicy(p.PathPrefix, p.AllowedOperations)) - .ToList(); - - ServiceTokenResult result = await vault.CreateServiceTokenAsync( - tenantId, request.Name, policies); - - return Results.Ok(new CreateTokenResponse( - Token: result.Token, - Name: result.Name, - Message: "Token created. This token is shown ONCE — store it securely.")); - } - catch (VaultSealedException) - { - return Results.StatusCode(503); - } - } -} - -public record CreateTokenRequest(string Name, List Policies); - -public record PolicyEntry(string PathPrefix, AccessOperation AllowedOperations); - -public record CreateTokenResponse(string Token, string Name, string Message); - -/// -/// POST /api/vault/tokens/validate — Validates a service token. -/// Token validation is global (not tenant-scoped) because the token -/// hash uniquely identifies which tenant it belongs to. -/// -public static class ValidateTokenEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/tokens/validate", Handle); - } - - private static async Task Handle(ValidateTokenRequest request, Vault vault) - { - TokenValidationResult result = await vault.ValidateTokenAsync(request.Token); - - return Results.Ok(new ValidateTokenResponse( - IsValid: result.IsValid, - Name: result.Name)); - } -} - -public record ValidateTokenRequest(string Token); - -public record ValidateTokenResponse(bool IsValid, string? Name); - -/// -/// POST /api/vault/tokens/revoke — Revokes a service token. -/// -public static class RevokeTokenEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/tokens/revoke", Handle); - } - - private static async Task Handle(RevokeTokenRequest request, Vault vault) - { - await vault.RevokeTokenAsync(request.Token); - - return Results.Ok(new { Message = "Token revoked." }); - } -} - -public record RevokeTokenRequest(string Token); - -/// -/// GET /api/vault/{tenantId}/tokens — Lists active service tokens for a tenant. -/// -public static class ListTokensEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapGet("/api/vault/{tenantId:guid}/tokens", Handle); - } - - private static async Task Handle(Guid tenantId, Vault vault) - { - try - { - List tokens = await vault.ListTokensAsync(tenantId); - - return Results.Ok(new ListTokensResponse( - Tokens: tokens, - Count: tokens.Count)); - } - catch (VaultSealedException) - { - return Results.StatusCode(503); - } - } -} - -public record ListTokensResponse(List Tokens, int Count); diff --git a/src/EntKube.Secrets/Features/Unseal/UnsealEndpoint.cs b/src/EntKube.Secrets/Features/Unseal/UnsealEndpoint.cs deleted file mode 100644 index e9bda08..0000000 --- a/src/EntKube.Secrets/Features/Unseal/UnsealEndpoint.cs +++ /dev/null @@ -1,45 +0,0 @@ -using EntKube.Secrets.Crypto; -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Features.Unseal; - -/// -/// POST /api/vault/{tenantId}/unseal — Manual (disaster recovery) unseal for -/// a specific tenant using Shamir shares. -/// -public static class UnsealEndpoint -{ - public static void Map(IEndpointRouteBuilder app) - { - app.MapPost("/api/vault/{tenantId:guid}/unseal", Handle); - } - - private static async Task Handle(Guid tenantId, UnsealRequest request, Vault vault) - { - try - { - ShamirShare share = ShamirShare.FromBase64(request.Share); - UnsealResult result = await vault.ProvideUnsealShareAsync(tenantId, share); - - return Results.Ok(new UnsealResponse( - IsUnsealed: result.IsUnsealed, - SharesProvided: result.SharesProvided, - SharesRequired: result.SharesRequired, - Message: result.IsUnsealed - ? "Tenant vault unsealed. Secret operations are now available." - : $"Share accepted. {result.SharesRequired - result.SharesProvided} more share(s) needed.")); - } - catch (InvalidOperationException ex) - { - return Results.BadRequest(new { Error = ex.Message }); - } - catch (Exception ex) - { - return Results.BadRequest(new { Error = $"Invalid share format: {ex.Message}" }); - } - } -} - -public record UnsealRequest(string Share); - -public record UnsealResponse(bool IsUnsealed, int SharesProvided, int SharesRequired, string Message); diff --git a/src/EntKube.Secrets/Infrastructure/EfVaultRepository.cs b/src/EntKube.Secrets/Infrastructure/EfVaultRepository.cs deleted file mode 100644 index 6363060..0000000 --- a/src/EntKube.Secrets/Infrastructure/EfVaultRepository.cs +++ /dev/null @@ -1,283 +0,0 @@ -using EntKube.Secrets.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Secrets.Infrastructure; - -/// -/// EF Core implementation of the vault repository. Multi-tenant aware — -/// each tenant has their own VaultState row for cryptographic isolation. -/// -public class EfVaultRepository : IVaultRepository -{ - private readonly IDbContextFactory dbFactory; - - public EfVaultRepository(IDbContextFactory dbFactory) - { - this.dbFactory = dbFactory; - } - - // ─── Vault State (per-tenant) ──────────────────────────────────────── - - public async Task GetStateAsync(Guid tenantId, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - VaultStateEntity? entity = await db.VaultStates - .FirstOrDefaultAsync(v => v.TenantId == tenantId, ct); - - if (entity is null) - { - return null; - } - - return new VaultState - { - TenantId = entity.TenantId, - EncryptedMasterKey = entity.EncryptedMasterKey, - MasterKeyVerifier = entity.MasterKeyVerifier, - ShamirThreshold = entity.ShamirThreshold - }; - } - - public async Task> GetAllStatesAsync(CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - List entities = await db.VaultStates.ToListAsync(ct); - - return entities.Select(e => new VaultState - { - TenantId = e.TenantId, - EncryptedMasterKey = e.EncryptedMasterKey, - MasterKeyVerifier = e.MasterKeyVerifier, - ShamirThreshold = e.ShamirThreshold - }).ToList(); - } - - public async Task SaveStateAsync(VaultState state, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - VaultStateEntity? existing = await db.VaultStates - .FirstOrDefaultAsync(v => v.TenantId == state.TenantId, ct); - - if (existing is null) - { - db.VaultStates.Add(new VaultStateEntity - { - TenantId = state.TenantId, - EncryptedMasterKey = state.EncryptedMasterKey, - MasterKeyVerifier = state.MasterKeyVerifier, - ShamirThreshold = state.ShamirThreshold - }); - } - else - { - existing.EncryptedMasterKey = state.EncryptedMasterKey; - existing.MasterKeyVerifier = state.MasterKeyVerifier; - existing.ShamirThreshold = state.ShamirThreshold; - } - - await db.SaveChangesAsync(ct); - } - - // ─── Encrypted Secrets ─────────────────────────────────────────────── - - public async Task GetSecretAsync(string path, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - SecretEntity? entity = await db.Secrets - .Include(s => s.Versions) - .FirstOrDefaultAsync(s => s.Path == path, ct); - - if (entity is null) - { - return null; - } - - return new PersistedSecret - { - Path = entity.Path, - TenantId = entity.TenantId, - IsDeleted = entity.IsDeleted, - Versions = entity.Versions.Select(v => new PersistedSecretVersion - { - VersionNumber = v.VersionNumber, - EncryptedDek = v.EncryptedDek, - EncryptedData = v.EncryptedData, - CreatedAt = v.CreatedAt - }).ToList() - }; - } - - public async Task SaveSecretAsync(PersistedSecret secret, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - SecretEntity? existing = await db.Secrets - .Include(s => s.Versions) - .FirstOrDefaultAsync(s => s.Path == secret.Path, ct); - - if (existing is null) - { - SecretEntity entity = new() - { - Path = secret.Path, - TenantId = secret.TenantId, - IsDeleted = secret.IsDeleted, - Versions = secret.Versions.Select(v => new SecretVersionEntity - { - SecretPath = secret.Path, - VersionNumber = v.VersionNumber, - EncryptedDek = v.EncryptedDek, - EncryptedData = v.EncryptedData, - CreatedAt = v.CreatedAt - }).ToList() - }; - - db.Secrets.Add(entity); - } - else - { - existing.IsDeleted = secret.IsDeleted; - - List toRemove = existing.Versions - .Where(ev => !secret.Versions.Any(sv => sv.VersionNumber == ev.VersionNumber)) - .ToList(); - - foreach (SecretVersionEntity remove in toRemove) - { - existing.Versions.Remove(remove); - } - - foreach (PersistedSecretVersion sv in secret.Versions) - { - if (!existing.Versions.Any(ev => ev.VersionNumber == sv.VersionNumber)) - { - existing.Versions.Add(new SecretVersionEntity - { - SecretPath = secret.Path, - VersionNumber = sv.VersionNumber, - EncryptedDek = sv.EncryptedDek, - EncryptedData = sv.EncryptedData, - CreatedAt = sv.CreatedAt - }); - } - } - } - - await db.SaveChangesAsync(ct); - } - - public async Task> ListSecretPathsAsync(string prefix, bool excludeDeleted = true, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - IQueryable query = db.Secrets.AsQueryable(); - - if (excludeDeleted) - { - query = query.Where(s => !s.IsDeleted); - } - - if (!string.IsNullOrEmpty(prefix)) - { - query = query.Where(s => s.Path.StartsWith(prefix)); - } - - return await query.Select(s => s.Path).ToListAsync(ct); - } - - // ─── Service Tokens ────────────────────────────────────────────────── - - public async Task GetTokenByHashAsync(string tokenHash, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - ServiceTokenEntity? entity = await db.ServiceTokens.FindAsync([tokenHash], ct); - - if (entity is null) - { - return null; - } - - return new PersistedServiceToken - { - TokenHash = entity.TokenHash, - TenantId = entity.TenantId, - Name = entity.Name, - Policies = entity.Policies, - CreatedAt = entity.CreatedAt, - IsRevoked = entity.IsRevoked - }; - } - - public async Task SaveTokenAsync(PersistedServiceToken token, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - ServiceTokenEntity? existing = await db.ServiceTokens.FindAsync([token.TokenHash], ct); - - if (existing is null) - { - db.ServiceTokens.Add(new ServiceTokenEntity - { - TokenHash = token.TokenHash, - TenantId = token.TenantId, - Name = token.Name, - Policies = token.Policies, - CreatedAt = token.CreatedAt, - IsRevoked = token.IsRevoked - }); - } - else - { - existing.IsRevoked = token.IsRevoked; - } - - await db.SaveChangesAsync(ct); - } - - public async Task> GetActiveTokensAsync(Guid tenantId, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - List entities = await db.ServiceTokens - .Where(t => !t.IsRevoked && t.TenantId == tenantId) - .ToListAsync(ct); - - return entities.Select(e => new PersistedServiceToken - { - TokenHash = e.TokenHash, - TenantId = e.TenantId, - Name = e.Name, - Policies = e.Policies, - CreatedAt = e.CreatedAt, - IsRevoked = e.IsRevoked - }).ToList(); - } - - // ─── Audit Log ─────────────────────────────────────────────────────── - - public async Task AppendAuditEntryAsync(AuditEntry entry, CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - - db.AuditEntries.Add(new AuditEntryEntity - { - Timestamp = entry.Timestamp, - Operation = entry.Operation, - Path = entry.Path, - Detail = entry.Detail - }); - - await db.SaveChangesAsync(ct); - } - - public async Task> GetAuditEntriesAsync(CancellationToken ct = default) - { - using SecretsDbContext db = await dbFactory.CreateDbContextAsync(ct); - List entities = await db.AuditEntries - .OrderByDescending(a => a.Timestamp) - .ToListAsync(ct); - - return entities.Select(e => new AuditEntry( - Timestamp: e.Timestamp, - Operation: e.Operation, - Path: e.Path, - Detail: e.Detail - )).ToList(); - } -} diff --git a/src/EntKube.Secrets/Infrastructure/FileBackedVaultRepository.cs b/src/EntKube.Secrets/Infrastructure/FileBackedVaultRepository.cs deleted file mode 100644 index 376d981..0000000 --- a/src/EntKube.Secrets/Infrastructure/FileBackedVaultRepository.cs +++ /dev/null @@ -1,328 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Infrastructure; - -/// -/// A development-friendly vault repository that keeps data in memory for fast -/// access but also persists to a JSON file on disk. Multi-tenant aware — -/// stores per-tenant vault states. -/// -public class FileBackedVaultRepository : IVaultRepository -{ - private readonly object lockObj = new(); - private readonly string filePath; - - private readonly Dictionary states = new(); - private readonly Dictionary secrets = new(StringComparer.Ordinal); - private readonly Dictionary tokens = new(StringComparer.Ordinal); - private readonly List auditLog = new(); - - private static readonly JsonSerializerOptions jsonOptions = new() - { - WriteIndented = true, - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() } - }; - - public FileBackedVaultRepository(IWebHostEnvironment env) - { - string dataDir = Path.Combine(env.ContentRootPath, "App_Data"); - Directory.CreateDirectory(dataDir); - filePath = Path.Combine(dataDir, "vault.json"); - - LoadFromDisk(); - } - - // ─── Vault State (per-tenant) ──────────────────────────────────────── - - public Task GetStateAsync(Guid tenantId, CancellationToken ct = default) - { - lock (lockObj) - { - states.TryGetValue(tenantId, out VaultState? state); - return Task.FromResult(state); - } - } - - public Task> GetAllStatesAsync(CancellationToken ct = default) - { - lock (lockObj) - { - return Task.FromResult(states.Values.ToList()); - } - } - - public Task SaveStateAsync(VaultState newState, CancellationToken ct = default) - { - lock (lockObj) - { - states[newState.TenantId] = newState; - FlushToDisk(); - } - - return Task.CompletedTask; - } - - // ─── Encrypted Secrets ─────────────────────────────────────────────── - - public Task GetSecretAsync(string path, CancellationToken ct = default) - { - lock (lockObj) - { - secrets.TryGetValue(path, out PersistedSecret? secret); - return Task.FromResult(secret); - } - } - - public Task SaveSecretAsync(PersistedSecret secret, CancellationToken ct = default) - { - lock (lockObj) - { - secrets[secret.Path] = secret; - FlushToDisk(); - } - - return Task.CompletedTask; - } - - public Task> ListSecretPathsAsync(string prefix, bool excludeDeleted = true, CancellationToken ct = default) - { - lock (lockObj) - { - List paths = secrets - .Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.Ordinal) - && (!excludeDeleted || !kvp.Value.IsDeleted)) - .Select(kvp => kvp.Key) - .ToList(); - - return Task.FromResult(paths); - } - } - - // ─── Service Tokens ────────────────────────────────────────────────── - - public Task GetTokenByHashAsync(string tokenHash, CancellationToken ct = default) - { - lock (lockObj) - { - tokens.TryGetValue(tokenHash, out PersistedServiceToken? token); - return Task.FromResult(token); - } - } - - public Task SaveTokenAsync(PersistedServiceToken token, CancellationToken ct = default) - { - lock (lockObj) - { - tokens[token.TokenHash] = token; - FlushToDisk(); - } - - return Task.CompletedTask; - } - - public Task> GetActiveTokensAsync(Guid tenantId, CancellationToken ct = default) - { - lock (lockObj) - { - List active = tokens.Values - .Where(t => !t.IsRevoked && t.TenantId == tenantId) - .ToList(); - - return Task.FromResult(active); - } - } - - // ─── Audit Log ─────────────────────────────────────────────────────── - - public Task AppendAuditEntryAsync(AuditEntry entry, CancellationToken ct = default) - { - lock (lockObj) - { - auditLog.Add(entry); - FlushToDisk(); - } - - return Task.CompletedTask; - } - - public Task> GetAuditEntriesAsync(CancellationToken ct = default) - { - lock (lockObj) - { - return Task.FromResult(auditLog.ToList()); - } - } - - // ─── Persistence Helpers ───────────────────────────────────────────── - - private void FlushToDisk() - { - VaultFileDto dto = new() - { - States = states.Values.Select(s => new VaultStateDto - { - TenantId = s.TenantId, - EncryptedMasterKey = Convert.ToBase64String(s.EncryptedMasterKey), - MasterKeyVerifier = Convert.ToBase64String(s.MasterKeyVerifier), - ShamirThreshold = s.ShamirThreshold - }).ToList(), - Secrets = secrets.Values.Select(s => new PersistedSecretDto - { - Path = s.Path, - TenantId = s.TenantId, - IsDeleted = s.IsDeleted, - Versions = s.Versions.Select(v => new SecretVersionDto - { - VersionNumber = v.VersionNumber, - EncryptedDek = Convert.ToBase64String(v.EncryptedDek), - EncryptedData = Convert.ToBase64String(v.EncryptedData), - CreatedAt = v.CreatedAt - }).ToList() - }).ToList(), - Tokens = tokens.Values.Select(t => new ServiceTokenDto - { - TokenHash = t.TokenHash, - TenantId = t.TenantId, - Name = t.Name, - Policies = t.Policies, - CreatedAt = t.CreatedAt, - IsRevoked = t.IsRevoked - }).ToList(), - AuditLog = auditLog.ToList() - }; - - string json = JsonSerializer.Serialize(dto, jsonOptions); - File.WriteAllText(filePath, json); - } - - private void LoadFromDisk() - { - if (!File.Exists(filePath)) - { - return; - } - - try - { - string json = File.ReadAllText(filePath); - VaultFileDto? dto = JsonSerializer.Deserialize(json, jsonOptions); - - if (dto is null) - { - return; - } - - if (dto.States is not null) - { - foreach (VaultStateDto s in dto.States) - { - VaultState state = new() - { - TenantId = s.TenantId, - EncryptedMasterKey = Convert.FromBase64String(s.EncryptedMasterKey), - MasterKeyVerifier = Convert.FromBase64String(s.MasterKeyVerifier), - ShamirThreshold = s.ShamirThreshold - }; - - states[state.TenantId] = state; - } - } - - if (dto.Secrets is not null) - { - foreach (PersistedSecretDto s in dto.Secrets) - { - PersistedSecret secret = new() - { - Path = s.Path, - TenantId = s.TenantId, - IsDeleted = s.IsDeleted, - Versions = s.Versions.Select(v => new PersistedSecretVersion - { - VersionNumber = v.VersionNumber, - EncryptedDek = Convert.FromBase64String(v.EncryptedDek), - EncryptedData = Convert.FromBase64String(v.EncryptedData), - CreatedAt = v.CreatedAt - }).ToList() - }; - - secrets[secret.Path] = secret; - } - } - - if (dto.Tokens is not null) - { - foreach (ServiceTokenDto t in dto.Tokens) - { - PersistedServiceToken token = new() - { - TokenHash = t.TokenHash, - TenantId = t.TenantId, - Name = t.Name, - Policies = t.Policies, - CreatedAt = t.CreatedAt, - IsRevoked = t.IsRevoked - }; - - tokens[token.TokenHash] = token; - } - } - - if (dto.AuditLog is not null) - { - auditLog.AddRange(dto.AuditLog); - } - } - catch (JsonException) - { - // Corrupted file — start fresh. - } - } - - // ─── Serialization DTOs ────────────────────────────────────────────── - - private class VaultFileDto - { - public List? States { get; set; } - public List? Secrets { get; set; } - public List? Tokens { get; set; } - public List? AuditLog { get; set; } - } - - private class VaultStateDto - { - public Guid TenantId { get; set; } - public string EncryptedMasterKey { get; set; } = string.Empty; - public string MasterKeyVerifier { get; set; } = string.Empty; - public int ShamirThreshold { get; set; } - } - - private class PersistedSecretDto - { - public string Path { get; set; } = string.Empty; - public Guid TenantId { get; set; } - public bool IsDeleted { get; set; } - public List Versions { get; set; } = new(); - } - - private class SecretVersionDto - { - public int VersionNumber { get; set; } - public string EncryptedDek { get; set; } = string.Empty; - public string EncryptedData { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } - } - - private class ServiceTokenDto - { - public string TokenHash { get; set; } = string.Empty; - public Guid TenantId { get; set; } - public string Name { get; set; } = string.Empty; - public List Policies { get; set; } = new(); - public DateTimeOffset CreatedAt { get; set; } - public bool IsRevoked { get; set; } - } -} diff --git a/src/EntKube.Secrets/Infrastructure/InMemoryVaultRepository.cs b/src/EntKube.Secrets/Infrastructure/InMemoryVaultRepository.cs deleted file mode 100644 index a8ac5e8..0000000 --- a/src/EntKube.Secrets/Infrastructure/InMemoryVaultRepository.cs +++ /dev/null @@ -1,137 +0,0 @@ -using EntKube.Secrets.Domain; - -namespace EntKube.Secrets.Infrastructure; - -/// -/// In-memory implementation of the vault repository. Used for unit tests -/// and local development. Production replaces this with PostgreSQL. -/// -/// Thread-safe for concurrent access within a single pod. For multi-instance -/// testing, multiple Vault instances can share the same InMemoryVaultRepository -/// to simulate pods sharing a database. -/// -public class InMemoryVaultRepository : IVaultRepository -{ - private readonly object lockObj = new(); - private readonly Dictionary states = new(); - private readonly Dictionary secrets = new(StringComparer.Ordinal); - private readonly Dictionary tokens = new(StringComparer.Ordinal); - private readonly List auditLog = new(); - - // ─── Vault State (per-tenant) ──────────────────────────────────────── - - public Task GetStateAsync(Guid tenantId, CancellationToken ct = default) - { - lock (lockObj) - { - states.TryGetValue(tenantId, out VaultState? state); - return Task.FromResult(state); - } - } - - public Task> GetAllStatesAsync(CancellationToken ct = default) - { - lock (lockObj) - { - return Task.FromResult(states.Values.ToList()); - } - } - - public Task SaveStateAsync(VaultState newState, CancellationToken ct = default) - { - lock (lockObj) - { - states[newState.TenantId] = newState; - } - - return Task.CompletedTask; - } - - // ─── Encrypted Secrets ─────────────────────────────────────────────── - - public Task GetSecretAsync(string path, CancellationToken ct = default) - { - lock (lockObj) - { - secrets.TryGetValue(path, out PersistedSecret? secret); - return Task.FromResult(secret); - } - } - - public Task SaveSecretAsync(PersistedSecret secret, CancellationToken ct = default) - { - lock (lockObj) - { - secrets[secret.Path] = secret; - } - - return Task.CompletedTask; - } - - public Task> ListSecretPathsAsync(string prefix, bool excludeDeleted = true, CancellationToken ct = default) - { - lock (lockObj) - { - List paths = secrets - .Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.Ordinal) - && (!excludeDeleted || !kvp.Value.IsDeleted)) - .Select(kvp => kvp.Key) - .ToList(); - - return Task.FromResult(paths); - } - } - - // ─── Service Tokens ────────────────────────────────────────────────── - - public Task GetTokenByHashAsync(string tokenHash, CancellationToken ct = default) - { - lock (lockObj) - { - tokens.TryGetValue(tokenHash, out PersistedServiceToken? token); - return Task.FromResult(token); - } - } - - public Task SaveTokenAsync(PersistedServiceToken token, CancellationToken ct = default) - { - lock (lockObj) - { - tokens[token.TokenHash] = token; - } - - return Task.CompletedTask; - } - - public Task> GetActiveTokensAsync(Guid tenantId, CancellationToken ct = default) - { - lock (lockObj) - { - List active = tokens.Values - .Where(t => !t.IsRevoked && t.TenantId == tenantId) - .ToList(); - - return Task.FromResult(active); - } - } - - // ─── Audit Log ─────────────────────────────────────────────────────── - - public Task AppendAuditEntryAsync(AuditEntry entry, CancellationToken ct = default) - { - lock (lockObj) - { - auditLog.Add(entry); - } - - return Task.CompletedTask; - } - - public Task> GetAuditEntriesAsync(CancellationToken ct = default) - { - lock (lockObj) - { - return Task.FromResult(auditLog.ToList()); - } - } -} diff --git a/src/EntKube.Secrets/Infrastructure/SecretsDbContext.cs b/src/EntKube.Secrets/Infrastructure/SecretsDbContext.cs deleted file mode 100644 index 1b96ccd..0000000 --- a/src/EntKube.Secrets/Infrastructure/SecretsDbContext.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Text.Json; -using EntKube.Secrets.Domain; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Secrets.Infrastructure; - -/// -/// The EF Core database context for the Secrets service. Maps vault state -/// (per-tenant), encrypted secrets with versions, service tokens, and audit entries. -/// -public class SecretsDbContext : DbContext -{ - public DbSet VaultStates => Set(); - public DbSet Secrets => Set(); - public DbSet SecretVersions => Set(); - public DbSet ServiceTokens => Set(); - public DbSet AuditEntries => Set(); - - public SecretsDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // Each tenant has exactly one VaultState row, keyed by TenantId. - - modelBuilder.Entity(entity => - { - entity.HasKey(v => v.TenantId); - entity.Property(v => v.EncryptedMasterKey).IsRequired(); - entity.Property(v => v.MasterKeyVerifier).IsRequired(); - entity.Property(v => v.ShamirThreshold); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(s => s.Path); - entity.Property(s => s.Path).HasMaxLength(500); - entity.Property(s => s.TenantId); - entity.Property(s => s.IsDeleted); - - entity.HasMany(s => s.Versions) - .WithOne() - .HasForeignKey(v => v.SecretPath) - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(entity => - { - entity.Property("Id").ValueGeneratedOnAdd(); - entity.HasKey("Id"); - entity.Property(v => v.SecretPath).IsRequired().HasMaxLength(500); - entity.Property(v => v.VersionNumber); - entity.Property(v => v.EncryptedDek).IsRequired(); - entity.Property(v => v.EncryptedData).IsRequired(); - entity.Property(v => v.CreatedAt); - }); - - modelBuilder.Entity(entity => - { - entity.HasKey(t => t.TokenHash); - entity.Property(t => t.TokenHash).HasMaxLength(200); - entity.Property(t => t.TenantId); - entity.Property(t => t.Name).IsRequired().HasMaxLength(200); - entity.Property(t => t.IsRevoked); - entity.Property(t => t.CreatedAt); - - entity.Property(t => t.Policies) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new()) - .HasColumnType("TEXT"); - }); - - modelBuilder.Entity(entity => - { - entity.Property("Id").ValueGeneratedOnAdd(); - entity.HasKey("Id"); - entity.Property(a => a.Timestamp); - entity.Property(a => a.Operation).HasMaxLength(100); - entity.Property(a => a.Path).HasMaxLength(500); - entity.Property(a => a.Detail).HasMaxLength(2000); - }); - } -} - -// ─── EF Entity types ───────────────────────────────────────────────────── - -public class VaultStateEntity -{ - public Guid TenantId { get; set; } - public byte[] EncryptedMasterKey { get; set; } = Array.Empty(); - public byte[] MasterKeyVerifier { get; set; } = Array.Empty(); - public int ShamirThreshold { get; set; } -} - -public class SecretEntity -{ - public string Path { get; set; } = string.Empty; - public Guid TenantId { get; set; } - public bool IsDeleted { get; set; } - public List Versions { get; set; } = new(); -} - -public class SecretVersionEntity -{ - public string SecretPath { get; set; } = string.Empty; - public int VersionNumber { get; set; } - public byte[] EncryptedDek { get; set; } = Array.Empty(); - public byte[] EncryptedData { get; set; } = Array.Empty(); - public DateTimeOffset CreatedAt { get; set; } -} - -public class ServiceTokenEntity -{ - public string TokenHash { get; set; } = string.Empty; - public Guid TenantId { get; set; } - public string Name { get; set; } = string.Empty; - public List Policies { get; set; } = new(); - public DateTimeOffset CreatedAt { get; set; } - public bool IsRevoked { get; set; } -} - -public class AuditEntryEntity -{ - public DateTimeOffset Timestamp { get; set; } - public string Operation { get; set; } = string.Empty; - public string? Path { get; set; } - public string? Detail { get; set; } -} diff --git a/src/EntKube.Secrets/Program.cs b/src/EntKube.Secrets/Program.cs deleted file mode 100644 index 8f524ec..0000000 --- a/src/EntKube.Secrets/Program.cs +++ /dev/null @@ -1,188 +0,0 @@ -using EntKube.Secrets.Domain; -using EntKube.Secrets.Infrastructure; -using EntKube.Secrets.Features.GetSecret; -using EntKube.Secrets.Features.Initialize; -using EntKube.Secrets.Features.PutSecret; -using EntKube.Secrets.Features.Seal; -using EntKube.Secrets.Features.Unseal; -using EntKube.Secrets.Features.Status; -using EntKube.Secrets.Features.ListSecrets; -using EntKube.Secrets.Features.DeleteSecret; -using EntKube.Secrets.Features.Tokens; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Secrets; - -public class Program -{ - public static async Task Main(string[] args) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - - // Register the database context via a factory. The Vault is singleton so it - // can't hold a scoped DbContext — instead it gets IDbContextFactory and creates - // a context per operation. - - string provider = builder.Configuration["DatabaseProvider"] ?? "Sqlite"; - string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=App_Data/secrets.db"; - - builder.Services.AddDbContextFactory(options => - { - switch (provider.ToLowerInvariant()) - { - case "postgres": - options.UseNpgsql(connectionString); - break; - - case "sqlserver": - options.UseSqlServer(connectionString); - break; - - default: - options.UseSqlite(connectionString); - break; - } - }); - - builder.Services.AddSingleton(); - - // The Vault itself is a singleton — it holds the MEK in memory. - // Each pod in Kubernetes has its own Vault instance with its own - // MEK, but they all share the same repository. - - builder.Services.AddSingleton(); - builder.Services.AddHealthChecks(); - builder.Services.AddOpenApi(); - - WebApplication app = builder.Build(); - - // Apply pending database migrations on startup. - - MigrateDatabase(app); - - // ─── Auto-Unseal on Startup ────────────────────────────────────── - // - // In Kubernetes, the KEK is mounted from a Secret as an environment - // variable or config file. On startup, we try to auto-unseal the vault - // by decrypting the stored MEK with the KEK. - // - // If the vault hasn't been initialized yet (first deployment), we - // log a message and let the operator call POST /api/vault/init. - - string? kekHex = builder.Configuration["Vault:Kek"]; - - if (!string.IsNullOrEmpty(kekHex)) - { - Vault vault = app.Services.GetRequiredService(); - - try - { - byte[] kek = Convert.FromHexString(kekHex); - await vault.AutoUnsealAsync(kek); - app.Logger.LogInformation("Vault auto-unsealed successfully"); - } - catch (InvalidOperationException ex) when (ex.Message.Contains("not initialized")) - { - app.Logger.LogInformation( - "No tenant vaults initialized yet. Call POST /api/vault/{tenantId}/init to initialize"); - } - catch (Exception ex) - { - app.Logger.LogError(ex, "Vault auto-unseal failed"); - } - } - else - { - app.Logger.LogWarning( - "Vault:Kek not configured. Set it via environment variable or Kubernetes Secret. " + - "The vault will not auto-unseal on startup"); - } - - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - } - - app.UseHttpsRedirection(); - - // ─── Vault Lifecycle Endpoints ─────────────────────────────────── - - InitializeEndpoint.Map(app); - SealEndpoint.Map(app); - UnsealEndpoint.Map(app); - StatusEndpoint.Map(app); - - // ─── Secret Operations ─────────────────────────────────────────── - - PutSecretEndpoint.Map(app); - GetSecretEndpoint.Map(app); - ListSecretsEndpoint.Map(app); - DeleteSecretEndpoint.Map(app); - - // ─── Service Token Management ──────────────────────────────────── - - CreateTokenEndpoint.Map(app); - ValidateTokenEndpoint.Map(app); - RevokeTokenEndpoint.Map(app); - ListTokensEndpoint.Map(app); - - // ─── Health Checks ─────────────────────────────────────────────── - - app.MapHealthChecks("/health"); - app.MapHealthChecks("/health/ready"); - - app.Run(); - } - - private static void MigrateDatabase(WebApplication app) - { - IDbContextFactory factory = app.Services.GetRequiredService>(); - using SecretsDbContext db = factory.CreateDbContext(); - ILogger logger = app.Services.GetRequiredService>(); - - // SQLite needs the directory to exist before it can create the database file. - - string? connectionString = db.Database.GetConnectionString(); - - if (connectionString != null) - { - Microsoft.Data.Sqlite.SqliteConnectionStringBuilder csb = new(connectionString); - - if (!string.IsNullOrEmpty(csb.DataSource) && csb.DataSource != ":memory:") - { - string? directory = Path.GetDirectoryName(Path.GetFullPath(csb.DataSource)); - - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - } - } - - int maxRetries = 5; - int delayMs = 1000; - - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - db.Database.EnsureCreated(); - logger.LogInformation("Database ready on attempt {Attempt}.", attempt); - return; - } - catch (Exception ex) when (attempt < maxRetries) - { - logger.LogWarning( - ex, - "Database setup attempt {Attempt}/{MaxRetries} failed. Retrying in {DelayMs}ms...", - attempt, - maxRetries, - delayMs); - - Thread.Sleep(delayMs); - delayMs *= 2; - } - } - } -} diff --git a/src/EntKube.Secrets/Properties/launchSettings.json b/src/EntKube.Secrets/Properties/launchSettings.json deleted file mode 100644 index bae2331..0000000 --- a/src/EntKube.Secrets/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5040", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7040;http://localhost:5040", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/EntKube.Secrets/appsettings.Development.json b/src/EntKube.Secrets/appsettings.Development.json deleted file mode 100644 index 476505f..0000000 --- a/src/EntKube.Secrets/appsettings.Development.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Vault": { - "Kek": "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F" - } -} diff --git a/src/EntKube.Secrets/appsettings.json b/src/EntKube.Secrets/appsettings.json deleted file mode 100644 index d0111f5..0000000 --- a/src/EntKube.Secrets/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/src/EntKube.SharedKernel/Contracts/ApiResponse.cs b/src/EntKube.SharedKernel/Contracts/ApiResponse.cs deleted file mode 100644 index 287bd73..0000000 --- a/src/EntKube.SharedKernel/Contracts/ApiResponse.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace EntKube.SharedKernel.Contracts; - -/// -/// Standard API response envelope used by all EntKube microservices. -/// Every HTTP response wraps its payload in this structure so clients -/// always know where to find the data, error messages, and metadata. -/// -public record ApiResponse -{ - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; - - public static ApiResponse Ok(T data) => new() - { - Success = true, - Data = data - }; - - public static ApiResponse Fail(string error) => new() - { - Success = false, - Error = error - }; -} diff --git a/src/EntKube.SharedKernel/Domain/Entity.cs b/src/EntKube.SharedKernel/Domain/Entity.cs deleted file mode 100644 index 94289ee..0000000 --- a/src/EntKube.SharedKernel/Domain/Entity.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace EntKube.SharedKernel.Domain; - -/// -/// Every domain entity in EntKube has a unique identifier. This base class -/// provides that identity and equality comparison so that two entities -/// are considered the same if they share the same Id — regardless of -/// whether their other properties differ. -/// -public abstract class Entity where TId : notnull -{ - public TId Id { get; protected set; } = default!; - - public override bool Equals(object? obj) - { - if (obj is not Entity other) - { - return false; - } - - return Id.Equals(other.Id); - } - - public override int GetHashCode() => Id.GetHashCode(); -} diff --git a/src/EntKube.SharedKernel/Domain/Result.cs b/src/EntKube.SharedKernel/Domain/Result.cs deleted file mode 100644 index 75a927b..0000000 --- a/src/EntKube.SharedKernel/Domain/Result.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace EntKube.SharedKernel.Domain; - -/// -/// A Result represents the outcome of an operation that can either succeed or fail. -/// Instead of throwing exceptions for expected failure scenarios (validation errors, -/// business rule violations, external service failures), we return a Result that -/// the caller can inspect and handle gracefully. -/// -public class Result -{ - public bool IsSuccess { get; } - public string? Error { get; } - public bool IsFailure => !IsSuccess; - - protected Result(bool isSuccess, string? error) - { - IsSuccess = isSuccess; - Error = error; - } - - public static Result Success() => new(true, null); - - public static Result Failure(string error) => new(false, error); - - public static Result Success(T value) => new(value, true, null); - - public static Result Failure(string error) => new(default, false, error); -} - -/// -/// A typed Result that carries a value on success. When the operation succeeds, -/// the Value property contains the result. When it fails, Error describes what went wrong. -/// -public class Result : Result -{ - public T? Value { get; } - - internal Result(T? value, bool isSuccess, string? error) - : base(isSuccess, error) - { - Value = value; - } -} diff --git a/src/EntKube.SharedKernel/EntKube.SharedKernel.csproj b/src/EntKube.SharedKernel/EntKube.SharedKernel.csproj deleted file mode 100644 index 770d62a..0000000 --- a/src/EntKube.SharedKernel/EntKube.SharedKernel.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net10.0 - enable - enable - - - diff --git a/src/EntKube.Web.Client/EntKube.Web.Client.csproj b/src/EntKube.Web.Client/EntKube.Web.Client.csproj index 4362d34..8de45fa 100644 --- a/src/EntKube.Web.Client/EntKube.Web.Client.csproj +++ b/src/EntKube.Web.Client/EntKube.Web.Client.csproj @@ -12,7 +12,6 @@ - diff --git a/src/EntKube.Web.Client/Pages/AppDetail.razor b/src/EntKube.Web.Client/Pages/AppDetail.razor deleted file mode 100644 index 8850679..0000000 --- a/src/EntKube.Web.Client/Pages/AppDetail.razor +++ /dev/null @@ -1,2521 +0,0 @@ -@page "/apps/{AppId:guid}" -@inject HttpClient Http -@inject NavigationManager Navigation -@rendermode InteractiveAuto - -App — @(app?.Name ?? "Loading...") - - - -@if (errorMessage is not null) -{ - -} - -@if (app is null) -{ -

Loading app...

-} -else -{ - @* ─── App Header ─── *@ -
-
-

@app.Name

-
- Slug: @app.Slug - @app.Type - @app.Status - Created @app.CreatedAt.ToString("yyyy-MM-dd") -
-
-
- @if (app.Status == "Active") - { - - } - else - { - - } - - -
-
- - @* ─── Environments Section ─── *@ -
-

Environments

- -
- - @if (app.Environments.Count == 0) - { -
-

No environments configured yet. Add this app to an environment to start deploying.

-
- } - else - { - @foreach (AppEnvironmentDto env in app.Environments) - { -
-
-
- @env.Namespace - Cluster: @env.ClusterId.ToString()[..8]... -
-
- @env.SyncStatus - -
-
-
- @if (env.SyncStatusMessage is not null) - { -
@env.SyncStatusMessage
- } - - @* ─── Deployment Configuration ─── *@ - @if (app.Type == "Deployment") - { - @if (env.DeploymentSpec is not null) - { -
Deployment Configuration
-
-
Image
@env.DeploymentSpec.Image:@env.DeploymentSpec.Tag
-
Replicas
@env.DeploymentSpec.Replicas
-
Ports
@env.DeploymentSpec.ContainerPort → @env.DeploymentSpec.ServicePort
-
Host
@(env.DeploymentSpec.HostName ?? "—")
-
- } - - - } - - @* ─── Helm Configuration ─── *@ - @if (app.Type == "HelmChart") - { - @if (env.HelmReleaseSpec is not null) - { -
Helm Release
-
-
Chart
@env.HelmReleaseSpec.ChartName
-
Version
@env.HelmReleaseSpec.ChartVersion
-
Repo
@env.HelmReleaseSpec.RepoUrl
-
- } - - - } - - @* ─── Secrets ─── *@ - - - - - @if (env.Secrets.Count > 0) - { -
-
Secrets
- - - - @foreach (AppSecretDto secret in env.Secrets) - { - - - - - - - } - -
Name (K8s key)Vault KeyValue
@secret.Name@secret.VaultKey - @if (revealedSecrets.ContainsKey(secret.VaultKey)) - { - @revealedSecrets[secret.VaultKey] - } - else - { - •••••••• - } - - - -
-
- } -
-
- } - } - - @* ─── Add Environment Form ─── *@ - @if (showAddEnvironment) - { -
-
-
Add Environment
- -
-
- - @if (tenantEnvironments is not null) - { - - - @foreach (EnvironmentDto env in tenantEnvironments) - { - - } - - } - else - { - - } -
-
- - @if (environmentClusters is not null) - { - - - @foreach (ClusterDto cluster in environmentClusters) - { - - } - - } - else if (!string.IsNullOrEmpty(newEnvironment.EnvironmentId)) - { -
Loading clusters...
- } - else - { -
Select an environment first
- } -
-
- - -
-
-
- - -
-
-
-
- } - - @* ─── Configure Deployment Form ─── *@ - @if (showDeploymentForm && editingEnvironmentId is not null) - { -
-
-
Configure Deployment
- - - @* ─── Image Registry ─── *@ -
Image Registry
-
-
- - - - - - -
- @if (deploymentForm.ImageRegistrySource == "Harbor") - { -
- - @if (harborProjects is not null) - { - - - @foreach (HarborProjectDto project in harborProjects) - { - - } - - } - else - { -
Loading projects...
- } -
-
- - -
-
- -
- } - @if (deploymentForm.ImageRegistrySource == "Custom") - { -
- - -
- } -
- - @* ─── Core Settings ─── *@ -
Container
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - @* ─── Environment Variables ─── *@ -
Environment Variables
- @if (deploymentForm.EnvironmentVariables.Count > 0) - { - @foreach (KeyValueFormEntry envVar in deploymentForm.EnvironmentVariables) - { -
-
- -
-
- -
-
- -
-
- } - } - - @{ - // Show secret env vars from the environment's secrets (read-only display). - // These are injected as secretKeyRef and can't be edited here. - - AppEnvironmentDto? currentEnv = app?.Environments?.FirstOrDefault(e => e.Id == editingEnvironmentId); - List? envSecrets = currentEnv?.Secrets?.Where(s => s.Usage == "env").ToList(); - } - - @if (envSecrets is not null && envSecrets.Count > 0) - { -
- Secret environment variables (from vault): -
- @foreach (AppSecretDto secret in envSecrets) - { -
-
- @secret.Name -
-
- @if (revealedSecrets.ContainsKey(secret.VaultKey)) - { - @revealedSecrets[secret.VaultKey] - } - else - { - •••••••• - } -
-
- -
-
- } - } - - - - @* ─── Services ─── *@ -
Services
-

Define Kubernetes Services for this app. Each service exposes ports with a protocol (TCP/UDP) and type (ClusterIP/NodePort/LoadBalancer). When defined, these replace the legacy single service above.

- @for (int i = 0; i < deploymentForm.Services.Count; i++) - { - int idx = i; -
-
- - -
-
- - -
-
- - -
-
- - - - - -
-
- - - - - - -
-
- -
-
- } - - - @* ─── Routes ─── *@ -
Routes
-

Define Gateway API routes. Supports HTTP, TLS, TCP, UDP, and gRPC route types. When defined, these replace the legacy HTTPRoute generated from Host Name above.

- @for (int i = 0; i < deploymentForm.Routes.Count; i++) - { - int idx = i; -
-
-
- - -
-
- - - - - - - - -
-
- - -
-
- - -
-
- -
-
-
-
- - -
-
- - -
-
-
- } - - - @* ─── Resources ─── *@ -
Resources
-
-
- -
- - / - -
-
-
- -
- - / - -
-
-
- - @* ─── Service Account ─── *@ -
Service Account
-
-
- - @if (availableServiceAccounts is not null) - { - - - @foreach (string sa in availableServiceAccounts) - { - - } - - } - else - { - - } -
-
- -
-
-
- - -
-
-
- - @* ─── Health Probes ─── *@ -
Health Probes
-
-
- - -
-
- - -
-
- - -
-
- - @* ─── Security Context ─── *@ -
Security Context
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - @* ─── Volumes ─── *@ -
Volumes
- @if (deploymentForm.Volumes.Count > 0) - { - @foreach (VolumeFormEntry vol in deploymentForm.Volumes) - { -
-
- -
-
- - - - - - -
-
- @if (vol.SourceType == "pvc" && availablePvcs is not null) - { - - - @foreach (PvcSummaryDto pvc in availablePvcs) - { - - } - - } - else - { - - } -
-
- -
-
-
- - -
-
-
- -
-
- } - } - - - - - - @* ─── Init Containers ─── *@ -
Init Containers
- @if (deploymentForm.InitContainers.Count > 0) - { - @foreach (ContainerFormEntry init in deploymentForm.InitContainers) - { -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- } - } - - - - @* ─── Sidecar Containers ─── *@ -
Sidecar Containers
- @if (deploymentForm.Sidecars.Count > 0) - { - @foreach (ContainerFormEntry sidecar in deploymentForm.Sidecars) - { -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- } - } - - - - @* ─── Annotations ─── *@ -
Annotations
- @if (deploymentForm.Annotations.Count > 0) - { - @foreach (KeyValueFormEntry ann in deploymentForm.Annotations) - { -
-
- -
-
- -
-
- -
-
- } - } - - - - @* ─── Labels ─── *@ -
Labels
- @if (deploymentForm.Labels.Count > 0) - { - @foreach (KeyValueFormEntry lbl in deploymentForm.Labels) - { -
-
- -
-
- -
-
- -
-
- } - } - - - -
- - -
-
-
-
- } - - @* ─── Configure Helm Form ─── *@ - @if (showHelmForm && editingEnvironmentId is not null) - { -
-
-
Configure Helm Release
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
- } - - @* ─── Add Secret Form ─── *@ - @if (showSecretForm && editingEnvironmentId is not null) - { -
-
-
Add Secret
-

Link a vault secret to this environment. Secrets injected as env vars appear as secretKeyRef entries in the Deployment. Secrets mounted as volumes create a VolumeSecretSource.

- - @* ─── Browse Existing Secrets ─── *@ -
- - -
- - @if (vaultSecretPaths is not null) - { - @if (vaultSecretPaths.Count == 0) - { -
No existing vault secrets found for this app scope. Create one first.
- } - else - { -
- - - - @foreach (string path in vaultSecretPaths) - { - @* Extract the vault key (last segment of the path) *@ - string vaultKey = path.Contains('/') ? path[(path.LastIndexOf('/') + 1)..] : path; - - - - - } - -
Vault Path
@path - -
-
- } - } - - @* ─── Secret Link Form ─── *@ - -
-
- - -
-
- - -
-
- - - - - -
-
- @if (secretForm.Usage == "volume") - { -
-
- - -
-
- - -
-
-
- - -
-
-
- } -
- - -
-
-
-
- } - - @* ─── Create New Secret Modal ─── *@ - @if (showCreateSecretModal) - { - - } - - @* ─── Create PVC Modal ─── *@ - @if (showCreatePvcModal) - { - - } -} - -@code { - [Parameter] public Guid AppId { get; set; } - - private AppDetailDto? app; - private List? tenantEnvironments; - private List? environmentClusters; - private string? errorMessage; - private bool isSubmitting; - - // Form visibility - private bool showAddEnvironment; - private bool showDeploymentForm; - private bool showHelmForm; - private bool showSecretForm; - private bool showCreateSecretModal; - private Guid? editingEnvironmentId; - - // Vault secret browsing state - private List? vaultSecretPaths; - private bool isLoadingVaultSecrets; - - // Create secret modal state - private string newSecretKey = ""; - private string newSecretValue = ""; - private bool isCreatingSecret; - private string? createSecretError; - - // Service account lookup state - private List? availableServiceAccounts; - private bool isLoadingServiceAccounts; - private string newServiceAccountName = ""; - private bool isCreatingServiceAccount; - - // PVC lookup state - private List? availablePvcs; - private bool isLoadingPvcs; - private bool showCreatePvcModal; - private string newPvcName = ""; - private string newPvcStorageSize = "1Gi"; - private string newPvcAccessMode = "ReadWriteOnce"; - private string? newPvcStorageClass; - private bool isCreatingPvc; - - // Harbor project lookup state - private List? harborProjects; - private bool isLoadingHarborProjects; - - // Secret reveal state - private Dictionary revealedSecrets = new(); - private string? revealingSecretKey; - - // Form models - private AddEnvironmentFormModel newEnvironment = new(); - private DeploymentFormModel deploymentForm = new(); - private HelmFormModel helmForm = new(); - private SecretFormModel secretForm = new(); - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadApp(); - } - - private async Task LoadApp() - { - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/apps/{AppId}"); - - if (response is { Success: true, Data: not null }) - { - app = response.Data; - - // Load the tenant's environments for the dropdown. - - await LoadTenantEnvironments(); - } - else - { - errorMessage = response?.Error ?? "Failed to load app."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load app: {ex.Message}"; - } - } - - private async Task LoadTenantEnvironments() - { - if (app is null) - { - return; - } - - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/tenants/{app.TenantId}/environments"); - - if (response is { Success: true, Data: not null }) - { - tenantEnvironments = response.Data; - } - } - catch - { - // Environments dropdown will be unavailable — not critical. - } - } - - /// - /// When the user picks an environment from the dropdown, we need to fetch - /// the clusters assigned to that environment so the cluster selector shows - /// only relevant options. We also reset any previous cluster selection. - /// - private async Task OnEnvironmentSelected() - { - newEnvironment.ClusterId = ""; - environmentClusters = null; - - if (!string.IsNullOrEmpty(newEnvironment.EnvironmentId)) - { - await LoadClustersForEnvironment(Guid.Parse(newEnvironment.EnvironmentId)); - } - } - - private async Task LoadClustersForEnvironment(Guid environmentId) - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>( - $"api/clusters?environmentId={environmentId}"); - - if (response is { Success: true, Data: not null }) - { - environmentClusters = response.Data; - } - } - catch - { - // Cluster dropdown will be unavailable — fall back gracefully. - } - } - - private async Task AddEnvironment() - { - if (app is null) - { - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/apps/{AppId}/environments", - new - { - EnvironmentId = Guid.Parse(newEnvironment.EnvironmentId), - ClusterId = Guid.Parse(newEnvironment.ClusterId), - Namespace = newEnvironment.Namespace - }); - - if (response.IsSuccessStatusCode) - { - showAddEnvironment = false; - newEnvironment = new(); - environmentClusters = null; - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to add environment: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to add environment: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task DeleteApp() - { - if (app is null) - { - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync($"api/apps/{AppId}"); - - if (response.IsSuccessStatusCode) - { - // Navigate back to the customer's app list. - - Navigation.NavigateTo($"/customers/{app.CustomerId}/apps?tenantId={app.TenantId}"); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to delete app: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to delete app: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task RemoveEnvironment(Guid environmentId) - { - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync( - $"api/apps/{AppId}/environments/{environmentId}"); - - if (response.IsSuccessStatusCode) - { - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to remove environment: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to remove environment: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task SuspendApp() - { - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/apps/{AppId}/suspend", null); - - if (response.IsSuccessStatusCode) - { - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to suspend app: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to suspend app: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task ActivateApp() - { - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/apps/{AppId}/activate", null); - - if (response.IsSuccessStatusCode) - { - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to activate app: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to activate app: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task TriggerSync(Guid environmentId) - { - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync( - $"api/apps/{AppId}/environments/{environmentId}/sync", null); - - if (response.IsSuccessStatusCode) - { - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to trigger sync: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to trigger sync: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task RemoveSecret(Guid environmentId, string secretName) - { - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync( - $"api/apps/{AppId}/environments/{environmentId}/secrets/{secretName}"); - - if (response.IsSuccessStatusCode) - { - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to remove secret: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to remove secret: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private void StartConfigureDeployment(AppEnvironmentDto env) - { - editingEnvironmentId = env.EnvironmentId; - showDeploymentForm = true; - showHelmForm = false; - showSecretForm = false; - - if (env.DeploymentSpec is not null) - { - DeploymentSpecDto s = env.DeploymentSpec; - - deploymentForm = new DeploymentFormModel - { - Image = s.Image, - Tag = s.Tag, - Replicas = s.Replicas, - ContainerPort = s.ContainerPort, - ServicePort = s.ServicePort, - HostName = s.HostName ?? "", - PathPrefix = s.PathPrefix ?? "/", - CpuRequest = s.Resources?.CpuRequest ?? "", - CpuLimit = s.Resources?.CpuLimit ?? "", - MemoryRequest = s.Resources?.MemoryRequest ?? "", - MemoryLimit = s.Resources?.MemoryLimit ?? "", - ServiceAccountName = s.ServiceAccountName ?? "", - LivenessPath = s.Probes?.Liveness?.Path ?? "", - ReadinessPath = s.Probes?.Readiness?.Path ?? "", - StartupPath = s.Probes?.Startup?.Path ?? "", - RunAsUser = s.SecurityContext?.RunAsUser, - RunAsGroup = s.SecurityContext?.RunAsGroup, - FsGroup = s.SecurityContext?.FsGroup, - RunAsNonRoot = s.SecurityContext?.RunAsNonRoot ?? false, - Volumes = s.Volumes?.Select(v => new VolumeFormEntry - { - Name = v.Name, - SourceType = v.SourceType ?? "pvc", - SourceName = v.SourceName ?? "", - MountPath = v.MountPath ?? "", - ReadOnly = v.ReadOnly - }).ToList() ?? new(), - InitContainers = s.Containers?.InitContainers?.Select(c => new ContainerFormEntry - { - Name = c.Name, - Image = c.Image, - Tag = c.Tag, - Port = c.ContainerPort, - Command = c.Command is not null ? string.Join(", ", c.Command) : "" - }).ToList() ?? new(), - Sidecars = s.Containers?.Sidecars?.Select(c => new ContainerFormEntry - { - Name = c.Name, - Image = c.Image, - Tag = c.Tag, - Port = c.ContainerPort, - Command = c.Command is not null ? string.Join(", ", c.Command) : "" - }).ToList() ?? new(), - Annotations = s.Annotations?.Select(kv => new KeyValueFormEntry { Key = kv.Key, Value = kv.Value }).ToList() ?? new(), - Labels = s.Labels?.Select(kv => new KeyValueFormEntry { Key = kv.Key, Value = kv.Value }).ToList() ?? new(), - EnvironmentVariables = s.EnvironmentVariables?.Select(kv => new KeyValueFormEntry { Key = kv.Key, Value = kv.Value }).ToList() ?? new(), - Services = s.Services?.Select(svc => new ServiceFormEntry - { - Name = svc.Name, - Port = svc.Port, - TargetPort = svc.TargetPort, - Protocol = svc.Protocol, - Type = svc.Type - }).ToList() ?? new(), - Routes = s.Routes?.Select(r => new RouteFormEntry - { - Name = r.Name, - Type = r.Type, - Hostnames = r.Hostnames is not null ? string.Join(", ", r.Hostnames) : "", - BackendServiceName = r.Rules?.FirstOrDefault()?.BackendRefs.FirstOrDefault()?.Name ?? "", - BackendServicePort = r.Rules?.FirstOrDefault()?.BackendRefs.FirstOrDefault()?.Port ?? 80, - PathPrefix = r.Rules?.FirstOrDefault()?.Matches?.FirstOrDefault()?.Value ?? "/" - }).ToList() ?? new(), - ImageRegistrySource = s.ImageRegistry?.Source ?? "Public", - HarborDomain = s.ImageRegistry?.HarborDomain ?? "", - HarborProject = s.ImageRegistry?.HarborProject ?? "", - PullSecretName = s.ImageRegistry?.PullSecretName ?? "" - }; - } - else - { - deploymentForm = new DeploymentFormModel(); - } - } - - private void StartConfigureHelm(AppEnvironmentDto env) - { - editingEnvironmentId = env.EnvironmentId; - showHelmForm = true; - showDeploymentForm = false; - showSecretForm = false; - - if (env.HelmReleaseSpec is not null) - { - helmForm = new HelmFormModel - { - RepoUrl = env.HelmReleaseSpec.RepoUrl, - ChartName = env.HelmReleaseSpec.ChartName, - ChartVersion = env.HelmReleaseSpec.ChartVersion, - ValuesYaml = env.HelmReleaseSpec.ValuesYaml ?? "" - }; - } - else - { - helmForm = new HelmFormModel(); - } - } - - private void StartAddSecret(AppEnvironmentDto env) - { - editingEnvironmentId = env.EnvironmentId; - showSecretForm = true; - showDeploymentForm = false; - showHelmForm = false; - secretForm = new SecretFormModel(); - vaultSecretPaths = null; - showCreateSecretModal = false; - } - - /// - /// Fetches existing vault secrets scoped to this app and environment. - /// The vault path convention is: apps/{customerId}/{appSlug}/{environmentId}/... - /// - private async Task LoadVaultSecrets() - { - if (app is null || editingEnvironmentId is null) - { - return; - } - - isLoadingVaultSecrets = true; - - try - { - string prefix = $"apps/{app.CustomerId}/{app.Slug}/{editingEnvironmentId}"; - VaultListResponse? response = await Http.GetFromJsonAsync( - $"api/vault/{app.TenantId}/secrets?prefix={Uri.EscapeDataString(prefix)}"); - - vaultSecretPaths = response?.Paths ?? new(); - } - catch - { - vaultSecretPaths = new(); - } - finally - { - isLoadingVaultSecrets = false; - } - } - - /// - /// When the user picks an existing vault secret from the browser list, - /// we pre-fill the form with the vault key and a suggested K8s key name. - /// - private void SelectVaultSecret(string vaultKey) - { - secretForm.VaultKey = vaultKey; - - // Suggest a K8s-friendly env var name: uppercase, hyphens to underscores. - - secretForm.Name = vaultKey.Replace('-', '_').ToUpperInvariant(); - } - - /// - /// Creates a new secret in the vault and pre-fills the Add Secret form - /// so the user can link it immediately. This allows creating secrets - /// without leaving the app detail view. - /// - private async Task CreateAndLinkSecret() - { - if (app is null || editingEnvironmentId is null) - { - return; - } - - if (string.IsNullOrWhiteSpace(newSecretKey) || string.IsNullOrWhiteSpace(newSecretValue)) - { - createSecretError = "Both key and value are required."; - return; - } - - isCreatingSecret = true; - createSecretError = null; - - try - { - // Store the secret in the vault at the app-scoped path. - - string path = $"apps/{app.CustomerId}/{app.Slug}/{editingEnvironmentId}/{newSecretKey}"; - - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/vault/{app.TenantId}/secrets/{path}", - new { Value = newSecretValue }); - - if (response.IsSuccessStatusCode) - { - // Pre-fill the form so the user just clicks "Add Secret". - - secretForm.VaultKey = newSecretKey; - secretForm.Name = newSecretKey.Replace('-', '_').ToUpperInvariant(); - - // Close the modal and refresh the vault list. - - showCreateSecretModal = false; - newSecretKey = ""; - newSecretValue = ""; - vaultSecretPaths = null; - } - else - { - string body = await response.Content.ReadAsStringAsync(); - createSecretError = $"Failed to create secret: {body}"; - } - } - catch (Exception ex) - { - createSecretError = $"Failed to create secret: {ex.Message}"; - } - finally - { - isCreatingSecret = false; - } - } - - /// - /// Fetches service accounts from the target cluster for the current environment's - /// namespace so the user can pick an existing one instead of typing a name. - /// - private async Task LoadServiceAccounts() - { - if (app is null || editingEnvironmentId is null) - { - return; - } - - AppEnvironmentDto? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == editingEnvironmentId); - - if (env is null) - { - return; - } - - isLoadingServiceAccounts = true; - - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/apps/clusters/{env.ClusterId}/namespaces/{env.Namespace}/service-accounts"); - - if (response is { Success: true, Data: not null }) - { - availableServiceAccounts = response.Data; - } - } - catch - { - // Fall back to manual input. - } - finally - { - isLoadingServiceAccounts = false; - } - } - - /// - /// Creates a new service account on the cluster so the user can select it. - /// - private async Task CreateServiceAccount() - { - if (app is null || editingEnvironmentId is null || string.IsNullOrWhiteSpace(newServiceAccountName)) - { - return; - } - - AppEnvironmentDto? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == editingEnvironmentId); - - if (env is null) - { - return; - } - - isCreatingServiceAccount = true; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/apps/clusters/{env.ClusterId}/namespaces/{env.Namespace}/service-accounts", - new { Name = newServiceAccountName }); - - if (response.IsSuccessStatusCode) - { - deploymentForm.ServiceAccountName = newServiceAccountName; - newServiceAccountName = ""; - await LoadServiceAccounts(); - } - } - catch - { - // Best-effort — user can still type manually. - } - finally - { - isCreatingServiceAccount = false; - } - } - - /// - /// Fetches PVCs from the target cluster for the current environment's namespace - /// so the user can select an existing PVC when configuring volumes. - /// - private async Task LoadPvcs() - { - if (app is null || editingEnvironmentId is null) - { - return; - } - - AppEnvironmentDto? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == editingEnvironmentId); - - if (env is null) - { - return; - } - - isLoadingPvcs = true; - - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/apps/clusters/{env.ClusterId}/namespaces/{env.Namespace}/pvcs"); - - if (response is { Success: true, Data: not null }) - { - availablePvcs = response.Data; - } - } - catch - { - // Fall back to manual input. - } - finally - { - isLoadingPvcs = false; - } - } - - /// - /// Creates a PVC on the cluster and refreshes the PVC list so the user - /// can select it in the volume form. - /// - private async Task CreatePvc() - { - if (app is null || editingEnvironmentId is null || string.IsNullOrWhiteSpace(newPvcName)) - { - return; - } - - AppEnvironmentDto? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == editingEnvironmentId); - - if (env is null) - { - return; - } - - isCreatingPvc = true; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/apps/clusters/{env.ClusterId}/namespaces/{env.Namespace}/pvcs", - new - { - Name = newPvcName, - StorageSize = newPvcStorageSize, - AccessMode = newPvcAccessMode, - StorageClass = string.IsNullOrWhiteSpace(newPvcStorageClass) ? (string?)null : newPvcStorageClass - }); - - if (response.IsSuccessStatusCode) - { - showCreatePvcModal = false; - newPvcName = ""; - newPvcStorageSize = "1Gi"; - newPvcAccessMode = "ReadWriteOnce"; - newPvcStorageClass = null; - await LoadPvcs(); - } - } - catch - { - // Best-effort. - } - finally - { - isCreatingPvc = false; - } - } - - /// - /// When the user changes the image registry source, we load Harbor projects - /// if Harbor is selected and the environment's cluster has Harbor installed. - /// - private async Task OnImageRegistrySourceChanged() - { - if (deploymentForm.ImageRegistrySource == "Harbor") - { - await LoadHarborProjects(); - } - } - - /// - /// Loads Harbor projects from the cluster's Harbor instance so the user - /// can pick which project to pull images from. - /// - private async Task LoadHarborProjects() - { - if (app is null || editingEnvironmentId is null) - { - return; - } - - AppEnvironmentDto? env = app.Environments.FirstOrDefault(e => e.EnvironmentId == editingEnvironmentId); - - if (env is null) - { - return; - } - - isLoadingHarborProjects = true; - - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>( - $"api/harbor/{env.ClusterId}/projects"); - - if (response is { Success: true, Data: not null }) - { - harborProjects = response.Data; - - // If Harbor returned a domain, pre-fill it. - - if (string.IsNullOrEmpty(deploymentForm.HarborDomain) && harborProjects.Count > 0) - { - // The domain comes from the cluster component config — the proxy - // endpoint doesn't return it directly, so we leave the field for - // the user to fill or it gets populated from the pull secret result. - } - } - } - catch - { - harborProjects = new(); - } - finally - { - isLoadingHarborProjects = false; - } - } - - /// - /// Reveals a secret value from the vault. If the secret is already revealed, - /// clicking again hides it. The value is fetched from the vault API. - /// - private async Task RevealSecretValue(string vaultKey) - { - if (app is null || editingEnvironmentId is null) - { - return; - } - - // Toggle: if already revealed, hide it. - - if (revealedSecrets.ContainsKey(vaultKey)) - { - revealedSecrets.Remove(vaultKey); - return; - } - - revealingSecretKey = vaultKey; - - try - { - string path = $"apps/{app.CustomerId}/{app.Slug}/{editingEnvironmentId}/{vaultKey}"; - - HttpResponseMessage response = await Http.GetAsync($"api/vault/{app.TenantId}/secrets/{path}"); - - if (response.IsSuccessStatusCode) - { - VaultSecretResponse? secretResponse = await response.Content.ReadFromJsonAsync(); - - if (secretResponse?.Data?.Data is not null && secretResponse.Data.Data.TryGetValue("value", out string? value)) - { - revealedSecrets[vaultKey] = value ?? ""; - } - else - { - revealedSecrets[vaultKey] = "(unable to read value)"; - } - } - else - { - revealedSecrets[vaultKey] = $"(error: {response.StatusCode})"; - } - } - catch (Exception ex) - { - revealedSecrets[vaultKey] = $"(error: {ex.Message})"; - } - finally - { - revealingSecretKey = null; - } - } - - private async Task SaveDeployment() - { - if (editingEnvironmentId is null) - { - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - // Build the volumes list from the form entries. - - List? volumes = deploymentForm.Volumes.Count > 0 - ? deploymentForm.Volumes - .Where(v => !string.IsNullOrWhiteSpace(v.Name)) - .Select(v => (object)new - { - v.Name, - SourceType = v.SourceType, - SourceName = v.SourceName, - MountPath = NullIfEmpty(v.MountPath), - v.ReadOnly - }).ToList() - : null; - - // Build init containers and sidecars from the form entries. - - List? initContainers = deploymentForm.InitContainers.Count > 0 - ? deploymentForm.InitContainers - .Where(c => !string.IsNullOrWhiteSpace(c.Name)) - .Select(c => (object)new - { - c.Name, - c.Image, - c.Tag, - ContainerPort = c.Port, - Command = !string.IsNullOrWhiteSpace(c.Command) - ? c.Command.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() - : (List?)null - }).ToList() - : null; - - List? sidecars = deploymentForm.Sidecars.Count > 0 - ? deploymentForm.Sidecars - .Where(c => !string.IsNullOrWhiteSpace(c.Name)) - .Select(c => (object)new - { - c.Name, - c.Image, - c.Tag, - ContainerPort = c.Port, - Command = !string.IsNullOrWhiteSpace(c.Command) - ? c.Command.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() - : (List?)null - }).ToList() - : null; - - // Build the probes spec from the form paths. - - object? probes = null; - - if (!string.IsNullOrWhiteSpace(deploymentForm.LivenessPath) || - !string.IsNullOrWhiteSpace(deploymentForm.ReadinessPath) || - !string.IsNullOrWhiteSpace(deploymentForm.StartupPath)) - { - probes = new - { - Liveness = !string.IsNullOrWhiteSpace(deploymentForm.LivenessPath) - ? new { Type = "HTTP", Path = deploymentForm.LivenessPath, Port = deploymentForm.ContainerPort, InitialDelaySeconds = 15, PeriodSeconds = 20, FailureThreshold = 3 } - : null, - Readiness = !string.IsNullOrWhiteSpace(deploymentForm.ReadinessPath) - ? new { Type = "HTTP", Path = deploymentForm.ReadinessPath, Port = deploymentForm.ContainerPort, InitialDelaySeconds = 5, PeriodSeconds = 10, FailureThreshold = 1 } - : null, - Startup = !string.IsNullOrWhiteSpace(deploymentForm.StartupPath) - ? new { Type = "HTTP", Path = deploymentForm.StartupPath, Port = deploymentForm.ContainerPort, InitialDelaySeconds = 0, PeriodSeconds = 5, FailureThreshold = 30 } - : null - }; - } - - // Build security context from the form. - - object? securityContext = null; - - if (deploymentForm.RunAsUser.HasValue || deploymentForm.RunAsGroup.HasValue || - deploymentForm.FsGroup.HasValue || deploymentForm.RunAsNonRoot) - { - securityContext = new - { - deploymentForm.RunAsUser, - deploymentForm.RunAsGroup, - deploymentForm.FsGroup, - RunAsNonRoot = deploymentForm.RunAsNonRoot ? true : (bool?)null - }; - } - - // Build the services list from the form entries. - - List? services = deploymentForm.Services.Count > 0 - ? deploymentForm.Services - .Where(s => !string.IsNullOrWhiteSpace(s.Name)) - .Select(s => (object)new - { - s.Name, - s.Port, - s.TargetPort, - s.Protocol, - s.Type - }).ToList() - : null; - - // Build the routes list from the form entries. - - List? routes = deploymentForm.Routes.Count > 0 - ? deploymentForm.Routes - .Where(r => !string.IsNullOrWhiteSpace(r.Name)) - .Select(r => - { - List? hostnames = !string.IsNullOrWhiteSpace(r.Hostnames) - ? r.Hostnames.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList() - : null; - - List? matches = (r.Type == "HTTP" || r.Type == "GRPC") && !string.IsNullOrWhiteSpace(r.PathPrefix) - ? new List { new { MatchType = "PathPrefix", Value = r.PathPrefix, Headers = (Dictionary?)null } } - : null; - - return (object)new - { - r.Name, - r.Type, - Hostnames = hostnames, - GatewayRef = (object?)null, - Rules = new List - { - new - { - Matches = matches, - BackendRefs = new List - { - new { Name = r.BackendServiceName, Port = r.BackendServicePort, Weight = (int?)null } - }, - Filters = (List?)null - } - } - }; - }).ToList() - : null; - - HttpResponseMessage response = await Http.PutAsJsonAsync( - $"api/apps/{AppId}/environments/{editingEnvironmentId}/deployment", - new - { - Image = deploymentForm.Image, - Tag = deploymentForm.Tag, - Replicas = deploymentForm.Replicas, - ContainerPort = deploymentForm.ContainerPort, - ServicePort = deploymentForm.ServicePort, - HostName = NullIfEmpty(deploymentForm.HostName), - PathPrefix = NullIfEmpty(deploymentForm.PathPrefix), - EnvironmentVariables = deploymentForm.EnvironmentVariables.Count > 0 - ? deploymentForm.EnvironmentVariables - .Where(e => !string.IsNullOrWhiteSpace(e.Key)) - .ToDictionary(e => e.Key, e => e.Value) - : null, - Resources = new - { - CpuRequest = NullIfEmpty(deploymentForm.CpuRequest), - CpuLimit = NullIfEmpty(deploymentForm.CpuLimit), - MemoryRequest = NullIfEmpty(deploymentForm.MemoryRequest), - MemoryLimit = NullIfEmpty(deploymentForm.MemoryLimit) - }, - Volumes = volumes, - Containers = (initContainers is not null || sidecars is not null) - ? new { InitContainers = initContainers, Sidecars = sidecars } - : null, - SecurityContext = securityContext, - ServiceAccountName = NullIfEmpty(deploymentForm.ServiceAccountName), - Annotations = deploymentForm.Annotations.Count > 0 - ? deploymentForm.Annotations - .Where(a => !string.IsNullOrWhiteSpace(a.Key)) - .ToDictionary(a => a.Key, a => a.Value) - : null, - Labels = deploymentForm.Labels.Count > 0 - ? deploymentForm.Labels - .Where(l => !string.IsNullOrWhiteSpace(l.Key)) - .ToDictionary(l => l.Key, l => l.Value) - : null, - Probes = probes, - ConfigMaps = (List?)null, - Services = services, - Routes = routes, - ImageRegistry = deploymentForm.ImageRegistrySource != "Public" - ? new - { - Source = deploymentForm.ImageRegistrySource, - HarborDomain = NullIfEmpty(deploymentForm.HarborDomain), - HarborProject = NullIfEmpty(deploymentForm.HarborProject), - PullSecretName = NullIfEmpty(deploymentForm.PullSecretName) - } - : null - }); - - if (response.IsSuccessStatusCode) - { - showDeploymentForm = false; - editingEnvironmentId = null; - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to save deployment: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to save deployment: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task SaveHelmRelease() - { - if (editingEnvironmentId is null) - { - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PutAsJsonAsync( - $"api/apps/{AppId}/environments/{editingEnvironmentId}/helm", - new - { - RepoUrl = helmForm.RepoUrl, - ChartName = helmForm.ChartName, - ChartVersion = helmForm.ChartVersion, - ValuesYaml = string.IsNullOrWhiteSpace(helmForm.ValuesYaml) ? (string?)null : helmForm.ValuesYaml - }); - - if (response.IsSuccessStatusCode) - { - showHelmForm = false; - editingEnvironmentId = null; - await LoadApp(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to save helm release: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to save helm release: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task AddSecret() - { - if (editingEnvironmentId is null) - { - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - // Step 1: Add the secret reference to the app environment (env var injection). - - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/apps/{AppId}/environments/{editingEnvironmentId}/secrets", - new { Name = secretForm.Name, VaultKey = secretForm.VaultKey }); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to add secret: {body}"; - return; - } - - // Step 2: If the user chose volume mount, we also need to update - // the deployment spec to add a volume + volume mount referencing - // the K8s Secret that the reconciler creates. The K8s Secret is - // always named after the app slug, so we reference it directly. - - if (secretForm.Usage == "volume" && !string.IsNullOrWhiteSpace(secretForm.MountPath)) - { - await LoadApp(); - - AppEnvironmentDto? env = app?.Environments - .FirstOrDefault(e => e.EnvironmentId == editingEnvironmentId); - - if (env?.DeploymentSpec is not null) - { - // Re-save the deployment spec with the new secret volume added. - // We load the current spec, add a volume for the secret, and PUT it back. - - DeploymentSpecDto currentSpec = env.DeploymentSpec; - List existingVolumes = currentSpec.Volumes?.ToList() ?? new(); - - string volumeName = $"secret-{secretForm.Name.ToLowerInvariant().Replace('_', '-')}"; - - existingVolumes.Add(new VolumeSpecDto( - Name: volumeName, - SourceType: "secret", - SourceName: app!.Slug, - MountPath: secretForm.MountPath, - SubPath: string.IsNullOrWhiteSpace(secretForm.SubPath) ? null : secretForm.SubPath, - ReadOnly: secretForm.ReadOnly)); - - // Rebuild volumes payload for the API. - - List volumePayload = existingVolumes - .Select(v => (object)new - { - v.Name, - v.SourceType, - v.SourceName, - v.MountPath, - v.SubPath, - v.ReadOnly - }).ToList(); - - await Http.PutAsJsonAsync( - $"api/apps/{AppId}/environments/{editingEnvironmentId}/deployment", - new - { - currentSpec.Image, - currentSpec.Tag, - currentSpec.Replicas, - currentSpec.ContainerPort, - currentSpec.ServicePort, - currentSpec.HostName, - currentSpec.PathPrefix, - currentSpec.EnvironmentVariables, - currentSpec.Resources, - Volumes = volumePayload, - currentSpec.Containers, - currentSpec.SecurityContext, - currentSpec.ServiceAccountName, - currentSpec.Annotations, - currentSpec.Labels, - currentSpec.Probes, - currentSpec.ConfigMaps, - currentSpec.Services, - currentSpec.Routes - }); - } - } - - showSecretForm = false; - editingEnvironmentId = null; - await LoadApp(); - } - catch (Exception ex) - { - errorMessage = $"Failed to add secret: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private static string? NullIfEmpty(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value; - - private static string GetTypeBadge(string type) => type switch - { - "Deployment" => "bg-info text-dark", - "HelmChart" => "bg-purple text-white", - _ => "bg-secondary" - }; - - private static string GetStatusBadge(string status) => status switch - { - "Active" => "bg-success", - "Suspended" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - private static string GetSyncBadge(string status) => status switch - { - "Synced" => "bg-success", - "Pending" => "bg-warning text-dark", - "OutOfSync" => "bg-info text-dark", - "Error" => "bg-danger", - _ => "bg-secondary" - }; - - // ─── DTOs ──────────────────────────────────────────────────────────── - - private record ApiResponse(bool Success, T? Data, string? Error, DateTimeOffset Timestamp); - - private record AppDetailDto( - Guid Id, Guid CustomerId, Guid TenantId, string Name, string Slug, - string Type, string Status, DateTimeOffset CreatedAt, - List Environments); - - private record AppEnvironmentDto( - Guid Id, Guid EnvironmentId, Guid ClusterId, string Namespace, - DeploymentSpecDto? DeploymentSpec, HelmReleaseSpecDto? HelmReleaseSpec, - List Secrets, - string SyncStatus, string? SyncStatusMessage, DateTimeOffset? LastSyncAt); - - private record DeploymentSpecDto( - string Image, string Tag, int Replicas, int ContainerPort, int ServicePort, - string? HostName, string? PathPrefix, - Dictionary? EnvironmentVariables, - ResourceSpecDto? Resources, - List? Volumes, - AdditionalContainersSpecDto? Containers, - PodSecurityContextSpecDto? SecurityContext, - string? ServiceAccountName, - Dictionary? Annotations, - Dictionary? Labels, - ProbesSpecDto? Probes, - List? ConfigMaps, - List? Services, - List? Routes, - ImageRegistrySpecDto? ImageRegistry = null); - - private record ResourceSpecDto(string? CpuRequest, string? CpuLimit, string? MemoryRequest, string? MemoryLimit); - - private record VolumeSpecDto(string Name, string? SourceType, string? SourceName, string? MountPath, string? SubPath, bool ReadOnly); - - private record PodSecurityContextSpecDto(long? RunAsUser, long? RunAsGroup, long? FsGroup, bool? RunAsNonRoot); - - private record AdditionalContainersSpecDto(List? InitContainers, List? Sidecars); - - private record ContainerSpecDto(string Name, string Image, string Tag, int ContainerPort, List? Command, List? Args, - Dictionary? EnvironmentVariables, ResourceSpecDto? Resources); - - private record ProbesSpecDto(ProbeSpecDto? Liveness, ProbeSpecDto? Readiness, ProbeSpecDto? Startup); - - private record ProbeSpecDto(string Type, string? Path, int Port, int InitialDelaySeconds, int PeriodSeconds, int FailureThreshold); - - private record ConfigMapSpecDto(string Name, Dictionary Data); - - private record ServiceSpecDto(string Name, int Port, int TargetPort, string Protocol, string Type); - - private record RouteSpecDto(string Name, string Type, List? Hostnames, GatewayReferenceDto? GatewayRef, List? Rules); - - private record GatewayReferenceDto(string Name, string? Namespace); - - private record RouteRuleDto(List? Matches, List BackendRefs, List? Filters); - - private record RouteMatchDto(string MatchType, string Value, Dictionary? Headers); - - private record RouteBackendRefDto(string Name, int Port, int? Weight); - - private record RouteFilterDto(string Type, Dictionary? HeadersToSet, string? RewritePath, string? RedirectUrl); - - private record HelmReleaseSpecDto(string RepoUrl, string ChartName, string ChartVersion, string? ValuesYaml); - - private record AppSecretDto(string Name, string VaultKey, string? Usage = "env"); - - private record PvcSummaryDto(string Name, string? StorageClass, string? Capacity, string Status); - - private record ImageRegistrySpecDto(string Source, string? HarborDomain, string? HarborProject, string? PullSecretName); - - private record HarborProjectDto(string Name, string Visibility, bool IsProxyCache); - - private record VaultSecretResponse(VaultSecretData? Data); - private record VaultSecretData(Dictionary? Data); - - private record EnvironmentDto(Guid Id, Guid TenantId, string Name, string Slug, string Status, DateTimeOffset CreatedAt); - - private record ClusterDto(Guid Id, string Name, string ApiServerUrl, string Status, string Provider, Guid TenantId, Guid EnvironmentId, DateTimeOffset? LastHealthCheckAt); - - // ─── Form Models ───────────────────────────────────────────────────── - - private class AddEnvironmentFormModel - { - public string EnvironmentId { get; set; } = ""; - public string ClusterId { get; set; } = ""; - public string Namespace { get; set; } = ""; - } - - private class DeploymentFormModel - { - public string Image { get; set; } = ""; - public string Tag { get; set; } = ""; - public int Replicas { get; set; } = 1; - public int ContainerPort { get; set; } = 8080; - public int ServicePort { get; set; } = 80; - public string HostName { get; set; } = ""; - public string PathPrefix { get; set; } = "/"; - public string CpuRequest { get; set; } = ""; - public string CpuLimit { get; set; } = ""; - public string MemoryRequest { get; set; } = ""; - public string MemoryLimit { get; set; } = ""; - public string ServiceAccountName { get; set; } = ""; - public string LivenessPath { get; set; } = ""; - public string ReadinessPath { get; set; } = ""; - public string StartupPath { get; set; } = ""; - public long? RunAsUser { get; set; } - public long? RunAsGroup { get; set; } - public long? FsGroup { get; set; } - public bool RunAsNonRoot { get; set; } - public List Volumes { get; set; } = new(); - public List InitContainers { get; set; } = new(); - public List Sidecars { get; set; } = new(); - public List Annotations { get; set; } = new(); - public List Labels { get; set; } = new(); - public List EnvironmentVariables { get; set; } = new(); - public List Services { get; set; } = new(); - public List Routes { get; set; } = new(); - - // Image registry — determines where images are pulled from and - // whether a pull secret is auto-created (Harbor) or manually specified. - - public string ImageRegistrySource { get; set; } = "Public"; - public string HarborDomain { get; set; } = ""; - public string HarborProject { get; set; } = ""; - public string PullSecretName { get; set; } = ""; - } - - private class VolumeFormEntry - { - public string Name { get; set; } = ""; - public string SourceType { get; set; } = "pvc"; - public string SourceName { get; set; } = ""; - public string MountPath { get; set; } = ""; - public bool ReadOnly { get; set; } - } - - private class ContainerFormEntry - { - public string Name { get; set; } = ""; - public string Image { get; set; } = ""; - public string Tag { get; set; } = "latest"; - public int Port { get; set; } - public string Command { get; set; } = ""; - } - - private class KeyValueFormEntry - { - public string Key { get; set; } = ""; - public string Value { get; set; } = ""; - } - - private class ServiceFormEntry - { - public string Name { get; set; } = ""; - public int Port { get; set; } = 80; - public int TargetPort { get; set; } = 8080; - public string Protocol { get; set; } = "TCP"; - public string Type { get; set; } = "ClusterIP"; - } - - private class RouteFormEntry - { - public string Name { get; set; } = ""; - public string Type { get; set; } = "HTTP"; - public string Hostnames { get; set; } = ""; - public string BackendServiceName { get; set; } = ""; - public int BackendServicePort { get; set; } = 80; - public string PathPrefix { get; set; } = "/"; - } - - private class HelmFormModel - { - public string RepoUrl { get; set; } = ""; - public string ChartName { get; set; } = ""; - public string ChartVersion { get; set; } = ""; - public string ValuesYaml { get; set; } = ""; - } - - private class SecretFormModel - { - public string Name { get; set; } = ""; - public string VaultKey { get; set; } = ""; - public string Usage { get; set; } = "envvar"; - public string MountPath { get; set; } = ""; - public string SubPath { get; set; } = ""; - public bool ReadOnly { get; set; } = true; - } - - private record VaultListResponse(string Prefix, List Paths, int Count); -} diff --git a/src/EntKube.Web.Client/Pages/Auth.razor b/src/EntKube.Web.Client/Pages/Auth.razor index fd81666..e243741 100644 --- a/src/EntKube.Web.Client/Pages/Auth.razor +++ b/src/EntKube.Web.Client/Pages/Auth.razor @@ -3,6 +3,7 @@ @using Microsoft.AspNetCore.Authorization @attribute [Authorize] +@rendermode InteractiveAuto Auth diff --git a/src/EntKube.Web.Client/Pages/ClusterDetail.razor b/src/EntKube.Web.Client/Pages/ClusterDetail.razor deleted file mode 100644 index ebae0b7..0000000 --- a/src/EntKube.Web.Client/Pages/ClusterDetail.razor +++ /dev/null @@ -1,9485 +0,0 @@ -@page "/clusters/{ClusterId:guid}" -@inject HttpClient Http -@inject NavigationManager Navigation -@inject IJSRuntime Js -@rendermode InteractiveAuto - -Cluster — @(cluster?.Name ?? "Loading...") - - - -@if (errorMessage is not null) -{ - -} - -@if (cluster is null) -{ -

Loading cluster...

-} -else -{ -
-

@cluster.Name

- @cluster.Status -
- -
-
-
-
-
Cluster Info
-
-
API Server
-
@cluster.ApiServerUrl
-
Status
-
@cluster.Status
-
Last Health Check
-
@(cluster.LastHealthCheckAt?.ToString("g") ?? "Never")
-
-
-
-
-
-
-
-
Quick Actions
-
- - - - Health Dashboard - -
- @if (cluster.Status != "Connected") - { - Cluster must be Connected before scanning components. Click "Check Connectivity" first. - } -
-
-
-
- - @* ─── Tabs ──────────────────────────────────────────────────────────── *@ - - - - @* ─── Overview Tab ──────────────────────────────────────────────────── *@ - - @if (activeTab == "overview") - { - - @* ─── Cloud Provider ────────────────────────────────────────────────── *@ - -
-
-
Cloud Provider
- @if (cluster.Provider is not null && cluster.Provider != "None") - { - @cluster.Provider - } -
-
- @if (!showProviderForm) - { - @if (cluster.Provider is null or "None") - { -

No cloud provider linked. Linking a provider enables API-driven management (e.g., Cleura OpenStack/Gardener).

- - } - else - { -
-
Provider
-
@cluster.Provider
-
Cleura Login
-
@cluster.ProviderUsername
-
Region
-
@cluster.ProviderRegion
-
OpenStack API
-
- @if (cluster.HasOpenStackCredentials) - { - Configured - @cluster.OpenStackUsername @@@cluster.OpenStackAuthUrl - } - else - { - Not configured - } -
-
-
- - - -
- @if (providerTestResult is not null) - { -
- @providerTestResult.Message - @if (providerTestResult.AvailableRegions is { Count: > 0 }) - { -
Available regions: @string.Join(", ", providerTestResult.AvailableRegions) - } -
- } - - @if (showTwoFactorInput) - { -
-
- - -
- - -
- } - } - } - else - { - -
Cleura Cloud REST API
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
OpenStack API (optional)
-

For infrastructure operations (compute, networking, storage). Download credentials from Cleura Cloud Management Panel.

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
- } -
-
- - } @* end Overview tab *@ - - @* ─── Components Tab ────────────────────────────────────────────────── *@ - - @if (activeTab == "components") - { - -
-
-
Components
- @if (adoptionReport is not null) - { - @adoptionReport.Status - } - else - { - Run "Scan Components" to detect installed components - } -
-
- @if (adoptionReport is null && !isAdopting) - { -
-

No components detected yet.

-

Click Scan Components to discover what's installed on this cluster.

-
- } - else if (isAdopting) - { -
- -

Scanning cluster components...

-
- } - else if (adoptionReport is not null) - { - - - - - - - - - - - - - @foreach (ComponentCheckResultDto component in adoptionReport.Components) - { - bool isCertComponent = component.ComponentName is "domain-ca" or "internal-ca"; - - - - - - - - - - } - -
ComponentStatusVersionDetailsIssuesActions
@component.ComponentName - - @component.Status - - @(component.Configuration?.Version ?? "—") - @if (isCertComponent && component.Status == "Installed") - { - @* Certificate components: show a count and link to Certificates tab *@ - @if (component.Configuration?.Values is not null - && component.Configuration.Values.TryGetValue( - component.ComponentName == "domain-ca" ? "domainCACount" : "caCount", - out string? caCountStr) - && int.TryParse(caCountStr, out int caCount)) - { - @caCount CA@(caCount != 1 ? "s" : "") detected - } - else - { - Installed - } - } - else if (component.Details.Count > 0) - { -
    - @foreach (string detail in component.Details.Take(3)) - { -
  • @detail
  • - } - - @if (component.Details.Count > 3) - { -
  • +@(component.Details.Count - 3) more
  • - } -
- } - else - { - - } -
- @if (component.MissingItems.Count > 0) - { -
    - @foreach (string item in component.MissingItems) - { -
  • @item
  • - } -
- } - else - { - - } -
- @if (component.Status == "NotInstalled") - { - - } - else if (component.Status == "Degraded") - { - - } - else if (isCertComponent && component.Status == "Installed") - { -
- - -
- } - else if (component.Status == "Installed") - { -
- - - -
- } -
- } -
-
- - @* ─── Uninstall Confirmation Modal ─── *@ - - @if (pendingUninstallComponent is not null) - { - - - } - - @* ─── Upgrade Component Modal ─── *@ - - @if (upgradeTargetComponent is not null) - { - - - } - - @* ─── Component Configuration Modal ─── *@ - - @if (activeModalComponent is not null) - { - - - } - - @* ─── Services on this Cluster (inside Components tab) ────────────── *@ - -
-
-
Services
- -
-
- @if (services is null) - { -
- Loading services... -
- } - else if (services.Count == 0) - { -
- No services provisioned on this cluster yet. -
- } - else - { - - - - - - - - - - - - @foreach (ServiceDto service in services) - { - - - - - - - - } - -
NameTypeNamespaceStateActions
@service.Name@service.ServiceType@service.Namespace - - @service.CurrentState - - - @if (service.CurrentState != "Decommissioned") - { - - } -
- } -
-
- - } @* end Components tab *@ - - @* ─── Storage Tab ───────────────────────────────────────────────────── *@ - - @if (activeTab == "storage") - { - - @* ─── Cleura S3 Object Storage ───────────────────────────────────── *@ - - @if (isLoadingStorage) - { -

Loading storage info...

- } - else if (storageInfo is not null && storageInfo.Available) - { -
-
-
- S3 Object Storage - Cleura -
- -
-
-
-
S3 Endpoint
-
@storageInfo.S3Endpoint
-
Region
-
@storageInfo.Region
-
- -

S3-compatible storage via Cleura OpenStack. Create EC2 credentials to get access/secret key pairs usable with any S3 client.

- - @if (storageMessage is not null) - { -
- @storageMessage -
- } - - @if (newS3Credential is not null) - { -
-
New S3 Credentials
-

Copy the secret key now — it cannot be retrieved later.

-
-
Access Key
-
@newS3Credential.AccessKey
-
Secret Key
-
@newS3Credential.SecretKey
-
Endpoint
-
@newS3Credential.S3Endpoint
-
Region
-
@newS3Credential.Region
-
-
- } - - @if (s3Credentials is not null && s3Credentials.Credentials.Count > 0) - { -
Existing Credentials
- - - - - - - - - - - @foreach (S3CredentialSummaryDto cred in s3Credentials.Credentials) - { - - - - - - - } - -
Access KeyRegionEndpoint
@cred.AccessKey@cred.Region@cred.S3Endpoint - -
- } - else - { -

No S3 credentials created yet. Click "Create Credentials" to generate an access/secret key pair.

- } -
-
- } - else if (storageInfo is not null && !storageInfo.Available) - { -
-
-
S3 Object Storage
-
-
-

@(storageInfo.Message ?? "S3 storage is not available. Link a Cleura cloud provider with OpenStack credentials on the Overview tab to enable S3.")

-
-
- } - - @* ─── S3 Storage Buckets ──────────────────────────────────────────── *@ - - @if (storageInfo is not null && storageInfo.Available) - { -
-
-
Storage Buckets
- -
-
- - @if (bucketMessage is not null) - { -
- @bucketMessage -
- } - - @if (showBucketCreateForm) - { -
-
New Bucket
-
-
- - -
-
-
- - -
-
-
- - -
-
-
- } - - @if (storageBuckets is null) - { -
Loading buckets...
- } - else if (storageBuckets.Count == 0 && !showBucketCreateForm) - { -

No storage buckets created yet. Create one to get dedicated S3 storage with its own credentials.

- } - else if (storageBuckets.Count > 0) - { - - - - - - - - - - - - @foreach (StorageBucketSummaryDto bucket in storageBuckets) - { - - - - - - - - - @if (expandedBucketId == bucket.Id && expandedBucketDetail is not null) - { - - - - } - } - -
Bucket NameRegionEncryptedCreated
@bucket.Name@bucket.Region - @if (bucket.Encrypted) - { - SSE-S3 - } - else - { - No - } - @bucket.CreatedAt.ToString("yyyy-MM-dd HH:mm") - -
-
-
Connection Details
-
-
Endpoint
-
@expandedBucketDetail.Endpoint
-
Bucket Name
-
@expandedBucketDetail.Name
-
Access Key
-
@expandedBucketDetail.AccessKey
-
Secret Key
-
@expandedBucketDetail.SecretKey
-
Region
-
@expandedBucketDetail.Region
-
-
-
- } -
-
- } - - @* ─── MinIO Tenants on this Cluster ──────────────────────────────── *@ - -
-
-
MinIO Storage Tenants
-
- - -
-
-
- @if (minioMessage is not null) - { -
- @minioMessage - -
- } - - @if (minioTenants is null) - { -
Loading MinIO tenants...
- } - else if (minioTenants.Count == 0) - { -
- No MinIO storage tenants on this cluster. Click "Scan MinIO" to discover existing tenants, or create a new one. -
- } - else - { -
- @foreach (MinioTenantSummaryDto mt in minioTenants) - { -
-
-
- @mt.Name - @mt.Status -
-
-
-
Namespace
-
@mt.Namespace
-
Capacity
-
@mt.TotalCapacity
-
Pools
-
@mt.PoolCount pools, @mt.TotalServers servers
-
Endpoint
-
@mt.Endpoint
-
- - @* ─── Expanded Details: Health + Buckets ─── *@ - - @if (expandedMinioTenantId == mt.Id) - { -
- - @if (minioTenantHealth is not null) - { -
Health
-
-
State
-
- - @minioTenantHealth.CurrentState - -
-
Pods
-
@minioTenantHealth.ReadyPods / @minioTenantHealth.TotalPods ready
-
Drives
-
- @minioTenantHealth.DrivesOnline online / @minioTenantHealth.DriveCount total - @if (minioTenantHealth.DrivesOffline > 0) - { - @minioTenantHealth.DrivesOffline offline - } -
- @if (minioTenantHealth.TotalCapacity is not null) - { -
Capacity
-
@minioTenantHealth.TotalCapacity
- } -
- } - else - { -
Loading health...
- } - - @if (minioTenantBuckets is not null) - { -
Buckets (@minioTenantBuckets.Count)
- - @if (!string.IsNullOrEmpty(minioBucketError)) - { -
@minioBucketError
- } - - @if (minioTenantBuckets.Count > 0) - { -
    - @foreach (string bucket in minioTenantBuckets) - { -
  • - @bucket - -
  • - } -
- } - else - { -

No buckets discovered.

- } - -
- - -
- } - else - { -
Loading buckets...
- } - -
-
Console
-
@mt.ConsoleEndpoint
-
Created
-
@mt.CreatedAt.ToString("yyyy-MM-dd HH:mm")
-
- } -
- -
-
- } -
- } -
-
- - @* ─── Create MinIO Tenant Form ────────────────────────────────────── *@ - - @if (showMinioCreateForm) - { -
-
-
Create MinIO Tenant
-
-
- - -
-
- - -
-
- -
Storage Pools
- @for (int i = 0; i < newMinioTenant.Pools.Count; i++) - { - int index = i; -
-
-
- Pool @(index + 1) - @if (newMinioTenant.Pools.Count > 1) - { - - } -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- } - - - @if (newMinioTenant.Pools.Count > 0) - { -
- Estimated capacity: @CalculateMinioCapacity(newMinioTenant.Pools) -
- } - -
- - -
-
-
- } - - @* ─── Configure MinIO Tenant ───────────────────────────────────────── *@ - - @if (configuringMinioTenant is not null) - { -
-
-
Configure: @configuringMinioTenant.Name
- -
-
- @for (int i = 0; i < minioConfigurePools.Count; i++) - { - int index = i; -
-
-
- Pool @(index + 1) - @if (minioConfigurePools.Count > 1) - { - - } -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- } - - -
- New estimated capacity: @CalculateMinioCapacity(minioConfigurePools) -
- - -
-
- } - - } @* end Storage tab *@ - - @* ─── Keycloak Tab ──────────────────────────────────────────────────── *@ - - @if (activeTab == "keycloak") - { -
-
-
Keycloak Realms
- -
-
- @if (keycloakRealms is null) - { -

Loading realms...

- } - else if (keycloakRealms.Count == 0) - { -

No Keycloak realms configured. Create one to get started.

- } - else - { -
- @foreach (KeycloakRealmDto realm in keycloakRealms) - { -
-
- @realm.Name - @realm.IdentityProviders.Count identity provider(s) - @realm.Organizations.Count organization(s) -
- @realm.Status -
- } -
- } -
-
- - @if (showCreateRealmForm) - { -
-
-
Create Keycloak Realm
-
- - -
- - -
-
- } - - @if (selectedRealm is not null) - { -
-
-
@selectedRealm.Name — Settings
-
-
- @* ─── Realm Branding ─── *@ -
Branding
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- - @* ─── Realm Pages ─── *@ -
Enabled Pages
-
- - -
-
- - -
-
- - -
-
- - -
- - -
- - @* ─── Identity Providers ─── *@ -
Identity Providers
- @if (selectedRealm.IdentityProviders.Count == 0) - { -

No identity providers configured.

- } - else - { - - - - @foreach (IdentityProviderDto idp in selectedRealm.IdentityProviders) - { - - - - - - - } - -
AliasTypeDisplay Name
@idp.Alias@idp.Type@idp.DisplayName
- } - - - @if (showAddIdpForm) - { -
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
- } - -
- - @* ─── Organizations ─── *@ -
Organizations
- @if (selectedRealm.Organizations.Count == 0) - { -

No organizations defined.

- } - else - { - @foreach (OrganizationDto org in selectedRealm.Organizations) - { -
-
- @org.Name - @if (org.Description is not null) - { - — @org.Description - } - @if (org.Children.Count > 0) - { - (@org.Children.Count sub-org(s)) - } -
- -
- } - } -
- - -
- -
- - -
-
- } - } @* end Keycloak tab *@ - - @* ─── Databases Tab ─────────────────────────────────────────────────── *@ - - @if (activeTab == "databases") - { - @* ─── PostgreSQL Clusters ───────────────────────────────────────────── *@ - -
-
-
PostgreSQL Clusters
-
- - -
-
-
- @if (pgClusters is null) - { -

Loading PostgreSQL clusters...

- } - else if (pgClusters.Count == 0) - { -

No PostgreSQL clusters found. Deploy the CNPG operator first, then create or discover clusters.

- } - else - { - @foreach (PgClusterDto pgCluster in pgClusters) - { -
-
-
- @pgCluster.Name - @pgCluster.Namespace - @pgCluster.Status - PG @pgCluster.PostgresVersion - @pgCluster.Instances instance(s) · @pgCluster.StorageSize -
-
- @{ - int dbCount = (expandedPgClusterId == pgCluster.Id && pgLiveDatabases is not null) - ? pgLiveDatabases.Count - : pgCluster.Databases.Count; - } - @dbCount db(s) - -
-
- - @if (expandedPgClusterId == pgCluster.Id) - { -
- - @* ─── Health Dashboard (top metrics row) ─── *@ - - @if (pgClusterHealth is not null) - { -
-
-
- Phase - @pgClusterHealth.Phase -
-
-
-
- Instances - - @pgClusterHealth.ReadyInstances / @pgClusterHealth.TotalInstances - -
-
-
-
- Primary - @(pgClusterHealth.CurrentPrimary ?? "—") -
-
-
-
- Timeline - @pgClusterHealth.CurrentTimeline -
-
-
-
- WAL Archived - @(pgClusterHealth.LatestWalArchived ?? "—") -
-
-
-
- Last Backup - @if (pgClusterHealth.LastSuccessfulBackup is not null) - { - @pgClusterHealth.LastSuccessfulBackup - } - else - { - - } -
-
-
-
- Earliest PITR - @if (pgClusterHealth.FirstRecoverabilityPoint is not null) - { - @pgClusterHealth.FirstRecoverabilityPoint - } - else - { - - } -
-
-
- - @* ─── Replication Info ─── *@ - - @if (pgClusterHealth.Replication is not null) - { -
-
-
- Streaming - @if (pgClusterHealth.Replication.StreamingActive) - { - Active - } - else - { - Inactive - } -
-
-
-
- Sync Replicas - @pgClusterHealth.Replication.SyncReplicas -
-
-
-
- Max Lag - @FormatBytes(pgClusterHealth.Replication.MaxLagBytes) -
-
-
- } - - @* ─── Per-Instance Table ─── *@ - - @if (pgClusterHealth.Instances is not null && pgClusterHealth.Instances.Count > 0) - { -
Instances
- - - - - - - - - - - - - - - @foreach (PgInstanceDto inst in pgClusterHealth.Instances) - { - - - - - - - - - - - } - -
PodRoleStatusCurrent LSNReceived LSNReplayed LSNLagTimeline
@inst.Name - @if (inst.Role == "primary") - { - primary - } - else - { - replica - } - - @if (inst.Ready) - { - ready - } - else - { - not ready - } - @(inst.CurrentLsn ?? "—")@(inst.ReceivedLsn ?? "—")@(inst.ReplayedLsn ?? "—") - @if (inst.ReplicationLagBytes.HasValue) - { - - @FormatBytes(inst.ReplicationLagBytes.Value) - - } - else - { - - } - @(inst.TimelineId ?? "—")
- } - } - else if (isLoadingPgHealth) - { -
- - Loading live health data... -
- } - else - { - @* ─── Fallback static info ─── *@ - -
-
- Status - @pgCluster.Status - @if (pgCluster.TargetVersion is not null) - { - → @pgCluster.TargetVersion - } -
-
- Backup Schedule - @(pgCluster.BackupSchedule ?? "Not configured") -
-
- Retention - @(pgCluster.BackupRetentionDays.HasValue ? $"{pgCluster.BackupRetentionDays}d" : "—") -
-
- Instances - @pgCluster.Instances (primary + replicas) -
-
- } - - @* ─── Cluster Actions ─── *@ - -
- - - - - - -
- - @if (showUpgradeForm && upgradeTargetPgClusterId == pgCluster.Id) - { -
-
Upgrade PostgreSQL Version
-

- Current version: @pgCluster.PostgresVersion. - CNPG performs a rolling upgrade — it promotes a replica with the new version, then - switches over the primary. This is a zero-downtime operation. -

- - @if (availableVersions is null) - { -
- - Loading available versions... -
- } - else - { -
-
- - -
-
- - -
-
- } -
- } - - @* ─── Databases ─── *@ - -
-
Databases
- @if (pgLiveDatabases is not null) - { - @pgLiveDatabases.Count live - } -
- - @{ - // Prefer the live database list (queried from PostgreSQL) when available. - // Fall back to the stored list from the domain aggregate if live listing - // failed or hasn't loaded yet. - - List displayDatabases = pgLiveDatabases ?? pgCluster.Databases; - } - - @if (displayDatabases.Count == 0) - { -

No databases found.

- } - else - { -
- @foreach (string db in displayDatabases) - { -
- -
- @db -
- @pgCluster.Name-rw.@(pgCluster.Namespace).svc:5432/@db -
-
- -
- } -
- } - - @* ─── Add Database Form ─── *@ - -
-
Add Database
-
-
- - -
-
- - -
-
- -
-
-
- - @* ─── Backups Table ─── *@ - -
-
Backup History
- -
- - @if (backupDiagnostics is not null) - { -
-
Backup Diagnostics
-

Cluster: @backupDiagnostics.ClusterName in @backupDiagnostics.Namespace

-

First Recoverability Point: @(backupDiagnostics.FirstRecoverabilityPoint ?? "none")

-

Last Successful Backup: @(backupDiagnostics.LastSuccessfulBackup ?? "none")

-

Backup CRs in namespace: @backupDiagnostics.TotalBackupCRsInNamespace

-

Backup CRs cluster-wide: @backupDiagnostics.TotalBackupCRsClusterWide

- - @if (backupDiagnostics.AllBackupCRs.Count > 0) - { -

All Backup CRs found:

- - - - - - - - - - - - - @foreach (BackupCRSummaryDto b in backupDiagnostics.AllBackupCRs) - { - - - - - - - - - } - -
NameNamespacePhaseCluster RefMethodStarted
@b.Name@b.Namespace@b.Phase@(b.ClusterRef ?? "—")@b.Method@b.StartedAt
- } - - @if (backupDiagnostics.ScheduledBackups.Count > 0) - { -

ScheduledBackup CRs:

- - - - - - - - - - - - @foreach (ScheduledBackupSummaryDto sb in backupDiagnostics.ScheduledBackups) - { - - - - - - - - } - -
NameNamespaceScheduleCluster RefLast Schedule
@sb.Name@sb.Namespace@sb.Schedule@(sb.ClusterRef ?? "—")@sb.LastScheduleTime
- } - - -
- } - - @if (pgClusterBackups is null || pgClusterBackups.Count == 0) - { -

No backups recorded yet.

- } - else - { - int totalBackups = pgClusterBackups.Count; - List pagedBackups = pgClusterBackups - .Skip(backupPage * backupPageSize) - .Take(backupPageSize) - .ToList(); - int totalPages = (int)Math.Ceiling(totalBackups / (double)backupPageSize); - -
- Showing @(backupPage * backupPageSize + 1)–@(Math.Min((backupPage + 1) * backupPageSize, totalBackups)) of @totalBackups backup(s) -
- - - - - - - - - - - - - @foreach (PgBackupDto backup in pagedBackups) - { - - - - - - - - } - -
NameStatusStartedSizeActions
@backup.Name - @backup.Phase - @(backup.StartedAt?.ToString("g") ?? "—")@(backup.BackupSize ?? "—") - @if (string.Equals(backup.Phase, "completed", StringComparison.OrdinalIgnoreCase)) - { - - } -
- - @if (totalPages > 1) - { - - } - } - - @* ─── Restore From Backup Form ─── *@ - - @if (showRestoreForm && restoreSourcePgClusterId == pgCluster.Id) - { -
-
Restore from Backup
-
-
- - -
-
- - -
-
- - -
-
-
- - - -
- - @* ─── Restore Preview Panel ─── *@ - - @if (restorePreview is not null) - { -
-
Restore Preview
-
-
- Source: @restorePreview.SourceClusterName - (@restorePreview.SourceNamespace) -
-
- Target: @restorePreview.RestoredClusterName -
-
- Method: @restorePreview.RecoveryMethod -
-
- PG Version: @restorePreview.PostgresVersion -
-
- Instances: @restorePreview.Instances × @restorePreview.StorageSize -
-
- Object Store: @restorePreview.DestinationPath -
-
- Earliest PITR: - @(restorePreview.FirstRecoverabilityPoint ?? "—") -
-
- Last Backup: - @(restorePreview.LastSuccessfulBackup ?? "—") -
-
- - @if (restorePreview.Warnings.Count > 0) - { -
- Warnings: -
    - @foreach (string warning in restorePreview.Warnings) - { -
  • @warning
  • - } -
-
- } - else - { -
- No issues detected. Ready to restore. -
- } -
- } -
- } - else if (!showRestoreForm) - { -
- -
- } - - @* ─── Connection Information ─── *@ - -
Connection Information
-
-
-
Primary (read-write)
-
- @pgCluster.Name-rw.@(pgCluster.Namespace).svc.cluster.local:5432 - -
-
-
-
Read-only replicas
-
- @pgCluster.Name-ro.@(pgCluster.Namespace).svc.cluster.local:5432 - -
-
-
-
Any instance
-
- @pgCluster.Name-r.@(pgCluster.Namespace).svc.cluster.local:5432 - -
-
-
-
Credentials secret
-
- @pgCluster.Name-app - (keys: username, password, dbname, host, port, uri) -
-
-
- - @* ─── Configuration ─── *@ - -
Configuration
-
-
- - -
-
- - -
-
- - -
-
- -
-
- - @* ─── Alerts / Notifications area ─── *@ - - @if (pgCluster.Status == "Degraded") - { -
- Warning: This cluster is in degraded state. Check pod status and storage usage in the cluster. -
- } - - @if (pgCluster.Status == "Upgrading") - { -
- Upgrading: Rolling upgrade to PostgreSQL @pgCluster.TargetVersion in progress. -
- } -
- } -
- } - } - - @if (!string.IsNullOrEmpty(pgMessage)) - { -
@pgMessage
- } -
-
- - @* ─── Create PostgreSQL Cluster Form ────────────────────────────────── *@ - - @if (showCreatePgClusterForm) - { -
-
-
Create PostgreSQL Cluster
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- } - - @* ─── Redis Instances ───────────────────────────────────────────────── *@ - -
-
-
Redis Instances
-
- - -
-
-
- @if (redisClusters is null) - { -

Loading Redis instances...

- } - else if (redisClusters.Count == 0) - { -

No Redis instances found. Install the Redis Operator first, then create or discover instances.

- } - else - { - @foreach (RedisClusterDto redis in redisClusters) - { -
-
-
- @redis.Name - @redis.Namespace - @redis.Status - Redis @redis.RedisVersion - @redis.Replicas replica(s) · @redis.StorageSize -
-
- @if (redis.SentinelEnabled) - { - Sentinel - } - -
-
- - @if (expandedRedisClusterId == redis.Id) - { -
- - @* ─── Health Dashboard ─── *@ - - @if (redisClusterHealth is not null) - { -
-
-
- Phase - @redisClusterHealth.Phase -
-
-
-
- Ready - - @redisClusterHealth.ReadyReplicas / @redisClusterHealth.DesiredReplicas - -
-
-
-
- Master - @(redisClusterHealth.MasterNode ?? "—") -
-
-
-
- Memory - @(redisClusterHealth.UsedMemory ?? "—") -
-
-
-
- Sentinel - @if (redisClusterHealth.SentinelActive) - { - Active - } - else - { - Disabled - } -
-
-
-
- Uptime - @(redisClusterHealth.UptimeSeconds.HasValue ? FormatUptime(redisClusterHealth.UptimeSeconds.Value) : "—") -
-
-
- - @* ─── Replica Table ─── *@ - - @if (redisClusterHealth.Replicas is not null && redisClusterHealth.Replicas.Count > 0) - { -
Replicas
- - - - - - - - - - - - - @foreach (RedisReplicaDto replica in redisClusterHealth.Replicas) - { - - - - - - - - - } - -
PodRoleStatusConnected ClientsMemoryReplication Offset
@replica.Name - @if (replica.Role == "master") - { - master - } - else - { - replica - } - - @if (replica.Ready) - { - ready - } - else - { - not ready - } - @(replica.ConnectedClients?.ToString() ?? "—")@(replica.UsedMemory ?? "—")@(replica.ReplicationOffset?.ToString() ?? "—")
- } - } - else if (isLoadingRedisHealth) - { -
- - Loading live health data... -
- } - else - { - @* ─── Fallback static info ─── *@ - -
-
- Status - @redis.Status - @if (redis.TargetVersion is not null) - { - → @redis.TargetVersion - } -
-
- Replicas - @redis.Replicas -
-
- Storage - @redis.StorageSize -
-
- Sentinel - @(redis.SentinelEnabled ? "Enabled" : "Disabled") -
-
- } - - @* ─── Actions ─── *@ - -
- - - - - -
- - @if (showRedisUpgradeForm && redisUpgradeTargetId == redis.Id) - { -
-
Upgrade Redis Version
-

- Current version: @redis.RedisVersion. - The operator performs a rolling upgrade of the Redis pods. -

- - @if (redisAvailableVersions is null) - { -
- - Loading available versions... -
- } - else - { -
-
- - -
-
- - -
-
- } -
- } - - @* ─── Connection Information ─── *@ - -
Connection Information
-
-
-
Redis Service
-
- @($"{redis.Name}.{redis.Namespace}.svc.cluster.local:6379") - -
-
- @if (redis.SentinelEnabled) - { -
-
Sentinel Service
-
- @($"{redis.Name}-sentinel.{redis.Namespace}.svc.cluster.local:26379") - -
-
- } -
-
Credentials Secret
-
- @redis.Name - (key: password) -
-
-
- - @* ─── Configuration ─── *@ - -
Configuration
-
-
- - -
-
- - -
-
- - -
-
- -
-
- - @* ─── Alerts ─── *@ - - @if (redis.Status == "Degraded") - { -
- Warning: This Redis instance is in degraded state. Check pod status and storage. -
- } - - @if (redis.Status == "Upgrading") - { -
- Upgrading: Rolling upgrade to Redis @redis.TargetVersion in progress. -
- } -
- } -
- } - } - - @if (!string.IsNullOrEmpty(redisMessage)) - { -
@redisMessage
- } -
-
- - @* ─── Create Redis Instance Form ────────────────────────────────────── *@ - - @if (showCreateRedisForm) - { -
-
-
Create Redis Instance
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- } - - @* ─── MongoDB Clusters ──────────────────────────────────────────────── *@ - -
-
-
MongoDB Clusters
-
- - -
-
-
- @if (mongoClusters is null) - { -

Loading MongoDB clusters...

- } - else if (mongoClusters.Count == 0) - { -

No MongoDB clusters found. Install the MongoDB Community Operator first, then create or discover clusters.

- } - else - { - @foreach (MongoClusterDto mongo in mongoClusters) - { -
-
-
- @mongo.Name - @mongo.Namespace - @mongo.Status - Mongo @mongo.MongoVersion - @mongo.Members member(s) · @mongo.StorageSize -
-
- @mongo.Databases.Count db(s) - -
-
- - @if (expandedMongoClusterId == mongo.Id) - { -
- - @* ─── Health Dashboard ─── *@ - - @if (mongoClusterHealth is not null) - { -
-
-
- Phase - @mongoClusterHealth.Phase -
-
-
-
- Ready - - @mongoClusterHealth.ReadyMembers / @mongoClusterHealth.TotalMembers - -
-
-
-
- Primary - @(mongoClusterHealth.CurrentPrimary ?? "—") -
-
-
-
- Oplog Window - @(mongoClusterHealth.OplogWindowHours)h -
-
-
-
- Last Backup - @(mongoClusterHealth.LastBackup ?? "—") -
-
-
-
- Replication - @if (mongoClusterHealth.Replication?.ReplicationActive == true) - { - Active - } - else - { - Inactive - } -
-
-
- - @* ─── Member Table ─── *@ - - @if (mongoClusterHealth.MemberDetails is not null && mongoClusterHealth.MemberDetails.Count > 0) - { -
Members
- - - - - - - - - - - - @foreach (MongoMemberDto member in mongoClusterHealth.MemberDetails) - { - - - - - - - - } - -
PodRoleStateReadyReplication Lag
@member.Name - @if (member.Role == "PRIMARY") - { - primary - } - else - { - secondary - } - @member.State - @if (member.Ready) - { - ready - } - else - { - not ready - } - @(member.ReplicationLagSeconds.HasValue ? $"{member.ReplicationLagSeconds}s" : "—")
- } - } - else if (isLoadingMongoHealth) - { -
- - Loading live health data... -
- } - else - { - @* ─── Fallback static info ─── *@ - -
-
- Status - @mongo.Status - @if (mongo.TargetVersion is not null) - { - → @mongo.TargetVersion - } -
-
- Members - @mongo.Members -
-
- Storage - @mongo.StorageSize -
-
- Databases - @mongo.Databases.Count -
-
- } - - @* ─── Actions ─── *@ - -
- - - - - -
- - @* ─── Upgrade MongoDB Version ─── *@ - - @if (showMongoUpgradeForm && mongoUpgradeTargetId == mongo.Id) - { -
-
Upgrade MongoDB Version
-

- Current version: @mongo.MongoVersion. - The operator performs a rolling upgrade — secondaries first, then primary stepdown. -

- - @if (mongoAvailableVersions is null) - { -
- - Loading available versions... -
- } - else - { -
-
- - -
-
- - -
-
- } -
- } - - @* ─── Connection Information ─── *@ - -
Connection Information
-
-
-
Connection String
-
- @($"mongodb://{mongo.Name}-svc.{mongo.Namespace}.svc.cluster.local:27017/?replicaSet={mongo.Name}") -
-
-
-
Credentials Secret
-
- @($"{mongo.Name}-admin-my-user") - (key: password) -
-
-
- - @* ─── Configuration ─── *@ - -
Configuration
-
-
- - -
-
- - -
-
- -
-
-
- } -
- } - } - - @if (!string.IsNullOrEmpty(mongoMessage)) - { -
@mongoMessage
- } -
-
- - @* ─── Create MongoDB Cluster Form ───────────────────────────────────── *@ - - @if (showCreateMongoForm) - { -
-
-
Create MongoDB Cluster
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- } - } @* end Databases tab *@ - - @* ─── Delete Database Cluster Confirmation Modal ─── *@ - - @if (pendingDeleteDbClusterId is not null) - { - - - } - - @* ─── Certificates Tab ──────────────────────────────────────────────── *@ - - @if (activeTab == "certificates") - { -
-

Certificate Authorities

-
- - -
-
- - @if (caMessage is not null) - { -
- @caMessage - -
- } - - @* ─── Create Internal CA Form ─────────────────────────────────── *@ - - @if (showCreateInternalCAForm) - { -
-
-
Create Internal CA
-

Creates a general-purpose CA for platform service certificates (mTLS, webhooks, etc.).

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- } - - @* ─── Create Domain CA Form ───────────────────────────────────── *@ - - @if (showCreateDomainCAForm) - { -
-
-
Create Domain CA
-

Creates a CA scoped to specific DNS domains. Optionally import an external certificate.

-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
- @if (importExternalCA) - { -
-
- - -
-
- - -
-
- } -
- - -
-
-
- } - - @* ─── CA List ──────────────────────────────────────────────────── *@ - - @if (certificateAuthorities is null) - { -
Loading CAs...
- } - else if (certificateAuthorities.Count == 0) - { -
-

No Certificate Authorities found on this cluster.

-

Use the buttons above to create an internal or domain CA, or install cert-manager first.

-
- } - else - { - -
- - - - - - - - - - - - - - - @foreach (CaDto ca in certificateAuthorities) - { - string rowId = $"ca-{ca.Name}"; - bool isExpanded = expandedCaRow == rowId; - - - - - - - - - - - - - @if (isExpanded) - { - - - - } - } - -
NameTypeStatusTrust BundleExpiresOrganization
- - - @ca.Name - @if (ca.IsExternal) - { - External - } - - @if (ca.Domains.Count > 0) - { -
- @string.Join(", ", ca.Domains) - } -
- @switch (ca.Type) - { - case "Internal": - Internal - break; - case "Domain": - Domain - break; - case "LetsEncrypt": - ACME - break; - } - @ca.Status - @if (ca.Type == "LetsEncrypt") - { - n/a - } - else if (ca.InTrustBundle) - { - - } - else - { - - } - - @if (ca.NotAfter is not null) - { - DateTimeOffset expiry = ca.NotAfter.Value; - string cls = expiry < DateTimeOffset.UtcNow ? "text-danger fw-bold" - : expiry < DateTimeOffset.UtcNow.AddDays(90) ? "text-warning" : ""; - - @expiry.ToString("yyyy-MM-dd") - } - else - { - - } - @(ca.Organization ?? "—") -
- @if (!ca.InTrustBundle && ca.Type != "LetsEncrypt") - { - - } - - @if (!ca.IsExternal && ca.Status != "Discovered" && ca.Status != "Trust-Only" && ca.Type != "LetsEncrypt") - { - - } - - @if (ca.Type != "LetsEncrypt") - { - - } - else - { - - } -
-
-
-
- @if (ca.Subject is not null) - { -

Subject: @ca.Subject

- } - - @if (ca.IssuerDN is not null) - { -

Issuer: @ca.IssuerDN

- } - - @if (ca.SecretName is not null) - { -

Secret: @ca.SecretName

- } -
-
- @if (ca.SerialNumber is not null) - { -

Serial: @ca.SerialNumber

- } - - @if (ca.Thumbprint is not null) - { -

Thumbprint: @ca.Thumbprint

- } - - @if (ca.NotBefore is not null) - { -

Valid from: @ca.NotBefore.Value.ToString("yyyy-MM-dd")

- } -
- - @if (ca.Type == "LetsEncrypt") - { - ComponentCheckResultDto? leComponent = adoptionReport?.Components - .FirstOrDefault(c => c.ComponentName.Equals("letsencrypt", StringComparison.OrdinalIgnoreCase)); - - Dictionary? issuerInfo = null; - - if (leComponent?.Configuration?.Values is not null) - { - Dictionary> leIssuerDetails = new(); - - foreach (KeyValuePair kvp in leComponent.Configuration.Values) - { - if (kvp.Key.StartsWith("issuer.")) - { - string[] parts = kvp.Key.Split('.', 3); - - if (parts.Length == 3) - { - if (!leIssuerDetails.ContainsKey(parts[1])) - { - leIssuerDetails[parts[1]] = new(); - } - - leIssuerDetails[parts[1]][parts[2]] = kvp.Value; - } - } - } - - leIssuerDetails.TryGetValue(ca.Name, out issuerInfo); - } - - @if (issuerInfo is not null) - { -
-
-
- @if (issuerInfo.TryGetValue("solverType", out string? solverType)) - { -

Solver: - @foreach (string solver in solverType.Split('+')) - { - @solver - } -

- } - - @if (issuerInfo.TryGetValue("acmeServer", out string? server)) - { -

Server: - @(server.Contains("staging") ? "Staging" : server.Contains("acme-v02") ? "Production" : server) -

- } - - @if (issuerInfo.TryGetValue("email", out string? issuerEmail)) - { -

Email: @issuerEmail

- } - - @if (issuerInfo.TryGetValue("dnsZones", out string? zones) && !string.IsNullOrEmpty(zones)) - { -

DNS Zones: - @foreach (string z in zones.Split(',')) - { - @z.Trim() - } -

- } -
-
- @if (issuerInfo.TryGetValue("dnsSolverProvider", out string? dnsProvider)) - { -

DNS Provider: @dnsProvider

- - @if (issuerInfo.TryGetValue("dnsSolverHostedZone", out string? zone)) - { -

Hosted Zone: @zone

- } - - @if (issuerInfo.TryGetValue("dnsSolverSecretName", out string? secret)) - { -

Secret: @secret

- } - - @if (dnsProvider == "azuredns") - { - @if (issuerInfo.TryGetValue("dnsSolverSubscriptionId", out string? subId)) - { -

Subscription: @subId

- } - - @if (issuerInfo.TryGetValue("dnsSolverResourceGroup", out string? rg)) - { -

Resource Group: @rg

- } - } - - @if (dnsProvider == "clouddns" && issuerInfo.TryGetValue("dnsSolverProject", out string? gcpProject)) - { -

GCP Project: @gcpProject

- } - } -
-
-
- } - else - { -
-

Run "Scan Components" to see ACME solver details.

-
- } - } -
-
-
- } - - @* ─── TLS Certificates Section ─── *@ - -
- -
-

TLS Certificates

- -
- - @if (isLoadingCertificates) - { -
-
- Scanning certificates... -
- } - else if (certificates is null || certificates.Count == 0) - { -

No TLS certificates found.

- } - else - { -
- - - - - - - - - - - - - - - @foreach (CertificateDto cert in certificates) - { - string certRowId = $"cert-{cert.Thumbprint ?? cert.SecretName}"; - bool isCertExpanded = expandedCertRow == certRowId; - - - - - - - - - - - - - @if (isCertExpanded) - { - - - - } - } - -
NameDomainsIssuerExpiresTrust BundleNamespace
- - - @cert.Name - @if (cert.IsWildcard) - { - wildcard - } - - @if (cert.Domains.Count > 0) - { - @foreach (string domain in cert.Domains.Take(3)) - { - @domain - } - - @if (cert.Domains.Count > 3) - { - +@(cert.Domains.Count - 3) - } - } - else - { - - } - - @{ - string? issuerShort = cert.IssuerDN?.Split(',') - .Select(s => s.Trim()) - .FirstOrDefault(s => s.StartsWith("CN="))? - .Substring(3); - } - @(issuerShort ?? cert.IssuerDN ?? "—") - - @if (cert.NotAfter is not null) - { - DateTimeOffset expiry = cert.NotAfter.Value; - string expiryClass = expiry < DateTimeOffset.UtcNow ? "text-danger fw-bold" - : expiry < DateTimeOffset.UtcNow.AddDays(30) ? "text-warning" : ""; - - @expiry.ToString("yyyy-MM-dd") - } - else - { - - } - - @if (cert.InTrustBundle) - { - - } - else - { - - } - @cert.Namespace - @if (!cert.InTrustBundle) - { - - } -
-
-
- @if (cert.Subject is not null) - { -

Subject: @cert.Subject

- } - - @if (cert.IssuerDN is not null) - { -

Issuer: @cert.IssuerDN

- } - -

Secret: @cert.SecretName - @cert.SecretType -

-
-
- @if (cert.SerialNumber is not null) - { -

Serial: @cert.SerialNumber

- } - - @if (cert.Thumbprint is not null) - { -

Thumbprint: @cert.Thumbprint

- } - - @if (cert.NotBefore is not null) - { -

Valid from: @cert.NotBefore.Value.ToString("yyyy-MM-dd")

- } - - @if (cert.Domains.Count > 3) - { -

All domains: @string.Join(", ", cert.Domains)

- } -
-
-
-
- } - } @* end Certificates tab *@ - - @* ─── Repositories Tab (Harbor + Gitea) ──────────────────────────────── *@ - - @if (activeTab == "harbor") - { -
-

Repositories

-
- - -
-
- - @* ─── Harbor Section ─── *@ -
-
-
Harbor Registry
-
-
- @if (harborDataProjects is null && !isLoadingHarborData) - { -

Harbor data has not been loaded yet. Click Refresh Harbor to load projects.

- } - else if (harborDataProjects is not null) - { - List regularProjects = harborDataProjects.Where(p => !p.IsProxyCache).ToList(); - List proxyCaches = harborDataProjects.Where(p => p.IsProxyCache).ToList(); - - @if (regularProjects.Count > 0) - { -
Standard Projects
- - - - - - - - - @foreach (HarborProjectListDto project in regularProjects) - { - - - - - } - -
NameVisibility
@project.Name@project.Visibility
- } - - @if (proxyCaches.Count > 0) - { -
Proxy Caches
- - - - - - - - - - @foreach (HarborProjectListDto project in proxyCaches) - { - - - - - - } - -
NameVisibilityType
@project.Name@project.VisibilityProxy Cache
- } - - @if (regularProjects.Count == 0 && proxyCaches.Count == 0) - { -

No projects found in Harbor.

- } - } -
-
- - @* ─── Gitea Section ─── *@ -
-
-
Gitea
-
-
- @if (giteaInfo is null && !isLoadingGiteaRunners) - { -

Gitea data has not been loaded yet. Click Refresh Gitea to load instance info.

- } - else if (giteaInfo is not null) - { -
-
-
- Version: - @if (giteaInfo.Version is not null) - { - v@giteaInfo.Version - } - else - { - Unknown - } -
-
-
- Replicas: @giteaInfo.Replicas -
-
- Actions: - @if (giteaInfo.ActionsEnabled) - { - Enabled - } - else - { - Disabled - } -
-
- - @* ─── Act Runners ─── *@ - @if (giteaInfo.ActionsEnabled && giteaInfo.Runners.Count > 0) - { -
Actions Runners
- - - - - - - - - - - @foreach (GiteaRunnerDto runner in giteaInfo.Runners) - { - - - - - - - } - -
NameStatusReadyRestarts
@runner.Name - - @runner.Phase - - - @if (runner.Ready) - { - - } - else - { - - } - @runner.RestartCount
- } - else if (giteaInfo.ActionsEnabled) - { -

No runners deployed yet.

- } - } -
-
- } @* end Repositories tab *@ - - @* ─── Settings Tab ──────────────────────────────────────────────────── *@ - - @if (activeTab == "settings") - { -
-
-
Allowed Container Registries
-
-
- @if (isLoadingSettings) - { -
- -

Loading settings from cluster...

-
- } - else if (clusterSettings is null) - { -
-

Could not load cluster settings. Make sure the cluster is connected and security policies are installed.

- -
- } - else - { -

- These registries are allowed by the Kyverno restrict-image-registries policy. - Pods using images from unlisted registries will be blocked or audited. -

- - @if (settingsMessage is not null) - { -
- @settingsMessage - -
- } - -
- @foreach (string registry in editableRegistries) - { -
- @registry - -
- } -
- -
- - -
- - - } -
-
- } @* end Settings tab *@ - - @* ─── Provision Service Form (shown in Components tab) ───────────── *@ - - @if (activeTab == "components" && showProvisionServiceForm) - { -
-
-
Provision New Service
- -
- - - - - - - -
-
- - -
-
- - -
- - -
-
-
- } -} - -@code { - [Parameter] - public Guid ClusterId { get; set; } - - private ClusterDetailDto? cluster; - private AdoptionReportDto? adoptionReport; - private List? services; - private List? minioTenants; - private ServiceFormModel newService = new(); - private MinioCreateFormModel newMinioTenant = new(); - private MinioTenantSummaryDto? configuringMinioTenant; - private List minioConfigurePools = new(); - private bool showProvisionServiceForm; - private bool showMinioCreateForm; - private bool isCreatingMinio; - private bool isConfiguringMinio; - private bool isAdoptingMinioTenants; - private Guid? expandedMinioTenantId; - private MinioTenantHealthDto? minioTenantHealth; - private List? minioTenantBuckets; - private string? minioBucketError; - private string minioNewBucketName = string.Empty; - private bool isCreatingMinioBucket; - private bool isDeletingMinioBucket; - private string? minioMessage; - private bool minioMessageIsError; - private StorageInfoDto? storageInfo; - private S3CredentialListDto? s3Credentials; - private bool isLoadingStorage; - private bool isCreatingS3Credential; - private bool isDeletingS3Credential; - private S3CredentialResultDto? newS3Credential; - private string? storageMessage; - private bool storageMessageIsError; - private List? storageBuckets; - private StorageBucketDetailDto? expandedBucketDetail; - private Guid? expandedBucketId; - private bool showBucketCreateForm; - private bool isCreatingBucket; - private bool isDeletingBucket; - private string newBucketName = string.Empty; - private bool newBucketEncrypted = true; - private string? bucketMessage; - private bool bucketMessageIsError; - private bool isCheckingConnectivity; - private bool isAdopting; - private bool isDeploying; - private bool isProvisioning; - private bool isConfiguring; - private bool isUninstalling; - private bool isUpgradingComponent; - private bool isLoadingComponentVersions; - private string? upgradeTargetComponent; - private string? upgradeCurrentComponentVersion; - private string selectedComponentUpgradeVersion = string.Empty; - private List componentAvailableVersions = new(); - private string? componentUpgradeError; - private string? pendingUninstallComponent; - private string? pendingInstallComponent; - private Dictionary installFormValues = new(); - private string? installMessage; - private bool installMessageIsError; - private bool showProviderForm; - private bool isSavingProvider; - private bool isTestingProvider; - private bool showTwoFactorInput; - private string twoFactorCode = string.Empty; - private string? verificationToken; - private ProviderFormModel providerForm = new(); - private ProviderTestResultDto? providerTestResult; - private string? errorMessage; - private string? selectedComponent; - private ComponentCheckResultDto? activeModalComponent; - private Dictionary configFormValues = new(); - private string newConfigKey = string.Empty; - private string newConfigValue = string.Empty; - private string? configMessage; - private List? componentSchemas; - - // Tab state - private string activeTab = "overview"; - - // Keycloak state - private List? keycloakRealms; - private KeycloakRealmDto? selectedRealm; - private bool showCreateRealmForm; - private bool isCreatingRealm; - private string newRealmName = string.Empty; - private bool showAddIdpForm; - private string newOrgName = string.Empty; - private RealmBrandingForm realmBrandingForm = new(); - private RealmPagesForm realmPagesForm = new(); - private IdentityProviderForm newIdpForm = new(); - - // Database tab state - private List? pgClusters; - private Guid? expandedPgClusterId; - private bool showCreatePgClusterForm; - private bool isCreatingPgCluster; - private bool isAdoptingPgClusters; - private bool isAddingDatabase; - private bool isConfiguringPgCluster; - private bool isLoadingPgHealth; - private bool isTriggeringPgBackup; - private bool isRestartingPgCluster; - private bool isFailingOverPg; - private bool isDeletingPgDb; - private PgClusterHealthDto? pgClusterHealth; - private List? pgClusterBackups; - private List? pgLiveDatabases; - private string newPgClusterName = string.Empty; - private string newPgClusterNamespace = "database"; - private string newPgClusterVersion = "17"; - private int newPgClusterInstances = 2; - private string newPgClusterStorageSize = "10Gi"; - private string newPgClusterBucketId = string.Empty; - private string newPgClusterBackupBucket = ""; - private string newDbName = string.Empty; - private string newDbOwner = string.Empty; - private int? pgConfigInstances; - private string? pgConfigBackupSchedule; - private int? pgConfigRetentionDays; - private string? pgMessage; - private bool pgMessageIsError; - private bool showRestoreForm; - private Guid? restoreSourcePgClusterId; - private string restoreClusterName = string.Empty; - private string? restoreBackupName; - private DateTime? restoreTargetTime; - private bool isRestoringBackup; - private bool isPreviewingRestore; - private RestorePreviewDto? restorePreview; - private bool isDiagnosingBackups; - private BackupDiagnosticsDto? backupDiagnostics; - private int backupPage; - private const int backupPageSize = 10; - private bool showUpgradeForm; - private Guid? upgradeTargetPgClusterId; - private List? availableVersions; - private string selectedUpgradeVersion = string.Empty; - private bool isUpgradingPgCluster; - - // Redis tab state - private List? redisClusters; - private Guid? expandedRedisClusterId; - private bool showCreateRedisForm; - private bool isCreatingRedis; - private bool isAdoptingRedisClusters; - private bool isConfiguringRedis; - private bool isLoadingRedisHealth; - private bool isRestartingRedis; - private bool isFailingOverRedis; - private RedisHealthDto? redisClusterHealth; - private string newRedisName = string.Empty; - private string newRedisNamespace = "redis"; - private string newRedisVersion = "7.4.8"; - private int newRedisReplicas = 3; - private string newRedisStorageSize = "8Gi"; - private string newRedisSentinel = "true"; - private int? redisConfigReplicas; - private string? redisConfigStorageSize; - private string redisConfigSentinel = "true"; - private string? redisMessage; - private bool redisMessageIsError; - private bool showRedisUpgradeForm; - private Guid? redisUpgradeTargetId; - private List? redisAvailableVersions; - private string selectedRedisUpgradeVersion = string.Empty; - private bool isUpgradingRedis; - - // MongoDB tab state - private List? mongoClusters; - private Guid? expandedMongoClusterId; - private bool showCreateMongoForm; - private bool isCreatingMongo; - private bool isAdoptingMongoClusters; - private bool isConfiguringMongo; - private bool isLoadingMongoHealth; - private bool isRestartingMongo; - private bool isSteppingDownMongo; - private MongoHealthDto? mongoClusterHealth; - private string newMongoName = string.Empty; - private string newMongoNamespace = "mongodb"; - private string newMongoVersion = "8.0.9"; - private int newMongoMembers = 3; - private string newMongoStorageSize = "10Gi"; - private string newMongoBucketId = string.Empty; - private string newMongoBackupBucket = ""; - private int? mongoConfigMembers; - private string? mongoConfigStorageSize; - private string? mongoMessage; - private bool mongoMessageIsError; - private bool showMongoUpgradeForm; - private Guid? mongoUpgradeTargetId; - private List? mongoAvailableVersions; - private string selectedMongoUpgradeVersion = string.Empty; - private bool isUpgradingMongo; - - // Database cluster delete state - private Guid? pendingDeleteDbClusterId; - private string? pendingDeleteDbClusterName; - private string? pendingDeleteDbClusterType; - private bool isDeletingDbCluster; - - // ─── Certificates tab state ────────────────────────────────────────── - - private List? certificateAuthorities; - private bool showCreateInternalCAForm; - private bool showCreateDomainCAForm; - private bool isCreatingCA; - private bool isDeletingOrRotatingCA; - private bool importExternalCA; - private string newInternalCAName = string.Empty; - private string newInternalCAOrganization = "EntKube Platform"; - private int newInternalCADurationDays = 3650; - private string newDomainCAName = string.Empty; - private string newDomainCADomains = string.Empty; - private int newDomainCADurationDays = 1825; - private string newDomainCATlsCert = string.Empty; - private string newDomainCATlsKey = string.Empty; - private string? caMessage; - private bool caMessageIsError; - private List? certificates; - private bool isLoadingCertificates; - private string? expandedCaRow; - private string? expandedCertRow; - - // ─── Settings tab state ────────────────────────────────────────────── - - private ClusterSettingsDto? clusterSettings; - private List editableRegistries = new(); - private bool isLoadingSettings; - private bool isSavingSettings; - private string newRegistryInput = string.Empty; - private string? settingsMessage; - private bool settingsMessageIsError; - - // ─── Repositories tab state (Harbor + Gitea) ────────────────────────── - - private List? harborDataProjects; - private bool isLoadingHarborData; - - private GiteaInfoDto? giteaInfo; - private bool isLoadingGiteaRunners; - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadCluster(); - await LoadServices(); - await LoadMinioTenants(); - await LoadSchemas(); - } - - private async Task LoadSchemas() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters/{ClusterId}/components/schemas"); - - if (response is not null && response.Success) - { - componentSchemas = response.Data; - } - } - catch - { - componentSchemas = null; - } - } - - private async Task LoadCluster() - { - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/clusters/{ClusterId}"); - - if (response is not null && response.Success) - { - cluster = response.Data; - - // If the cluster has persisted components from a previous scan, - // display them immediately without requiring a new adoption scan. - - if (cluster?.Components is { Count: > 0 }) - { - List components = cluster.Components - .Select(c => new ComponentCheckResultDto( - c.ComponentName, - c.Status, - new List(), - new List(), - new DiscoveredConfigurationDto(c.Version, c.Namespace, c.HelmReleaseName, c.Configuration))) - .ToList(); - - string overallStatus = components.Any(c => c.Status == "NotInstalled") ? "NotReady" - : components.Any(c => c.Status == "Degraded") ? "PartiallyReady" - : "Ready"; - - adoptionReport = new AdoptionReportDto(overallStatus, components); - } - } - else - { - errorMessage = response?.Error ?? "Failed to load cluster."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load cluster: {ex.Message}"; - } - } - - private async Task LoadServices() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters/{ClusterId}/services"); - - if (response is not null && response.Success) - { - services = response.Data ?? new List(); - } - else - { - services = new List(); - } - } - catch - { - services = new List(); - } - } - - /// - /// Probes the cluster's API server to verify connectivity. On success, the - /// cluster status moves to "Connected" and adoption becomes available. - /// - private async Task CheckConnectivity() - { - isCheckingConnectivity = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/clusters/{ClusterId}/check-connectivity", null); - string body = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode) - { - // Reload the cluster to reflect the updated status. - - await LoadCluster(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Connectivity check failed."; - } - } - catch (Exception ex) - { - errorMessage = $"Connectivity check failed: {ex.Message}"; - } - finally - { - isCheckingConnectivity = false; - } - } - - /// - /// Triggers the adoption scan. This contacts the cluster API, inspects all - /// registered components (19 checks), and returns a report showing what's - /// installed, degraded, or missing. The user can then install missing - /// components directly from the results. - /// - private async Task RunAdoption() - { - isAdopting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/clusters/{ClusterId}/adopt", null); - - if (response.IsSuccessStatusCode) - { - ApiResponse? result = - await response.Content.ReadFromJsonAsync>(); - - if (result?.Data is not null) - { - adoptionReport = result.Data; - } - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Adoption scan failed."; - } - } - catch (Exception ex) - { - errorMessage = $"Adoption scan failed: {ex.Message}"; - } - finally - { - isAdopting = false; - } - } - - /// - /// Deploys a single component on this cluster. Called when the user clicks - /// "Install" next to a missing component in the adoption report. - /// - /// - /// Opens the pre-install configuration panel for a component. This lets the - /// user review and set parameters before deploying. Schema defaults are - /// pre-populated so the user only needs to fill in required values. - /// - private void ShowInstallForm(string componentName) - { - pendingInstallComponent = componentName; - selectedComponent = null; - activeModalComponent = adoptionReport?.Components - .FirstOrDefault(c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - installFormValues = new Dictionary(); - installMessage = null; - installMessageIsError = false; - - // Pre-populate with schema defaults so the form shows current values. - - ConfigurationSchemaDto? schema = componentSchemas?.FirstOrDefault( - s => s.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - - if (schema is not null) - { - foreach (ConfigurationParameterDto param in schema.Parameters) - { - if (param.DefaultValue is not null) - { - installFormValues[param.Key] = param.DefaultValue; - } - } - - // If any parameter is a StorageBucket dropdown, make sure the - // bucket list is loaded so the dropdown has options to show. - - bool hasStorageBucketParam = schema.Parameters.Any(p => p.Type == "StorageBucket"); - - if (hasStorageBucketParam && storageBuckets is null) - { - _ = LoadBuckets(); - } - } - } - - private string GetInstallValue(string key) - { - return installFormValues.TryGetValue(key, out string? value) ? value : string.Empty; - } - - private void UpdateInstallValue(string key, string value) - { - installFormValues[key] = value; - } - - private void UpdateInstallBoolValue(string key, ChangeEventArgs e) - { - installFormValues[key] = e.Value is true ? "true" : "false"; - } - - /// - /// When the user selects a PostgreSQL cluster from the dropdown during install, - /// we store the cluster name AND automatically fill in the cnpgNamespace field - /// so the installer knows where to find the CNPG cluster. - /// - private void OnPostgresClusterSelectedForInstall(string key, string value) - { - installFormValues[key] = value; - - if (!string.IsNullOrEmpty(value) && pgClusters is not null) - { - PgClusterDto? selected = pgClusters.FirstOrDefault(pg => pg.Name == value); - - if (selected is not null) - { - installFormValues["cnpgNamespace"] = selected.Namespace; - } - } - else - { - installFormValues.Remove("cnpgNamespace"); - } - } - - /// - /// Deploys a component with the parameters collected from the pre-install form. - /// Non-empty values are sent as the Parameters dictionary in the deploy request. - /// - private async Task DeployWithParameters(string componentName) - { - isDeploying = true; - installMessage = null; - installMessageIsError = false; - errorMessage = null; - - try - { - // Only send parameters that have a non-empty value. - - Dictionary parameters = installFormValues - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - - DeployComponentRequest request = new(componentName, Parameters: parameters.Count > 0 ? parameters : null); - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/clusters/{ClusterId}/components/deploy", request); - - if (response.IsSuccessStatusCode) - { - pendingInstallComponent = null; - activeModalComponent = null; - installFormValues = new Dictionary(); - - // Re-run adoption to refresh component statuses. - - await RunAdoption(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - installMessage = errorResponse?.Error ?? $"Failed to deploy {componentName}."; - installMessageIsError = true; - } - } - catch (Exception ex) - { - installMessage = $"Failed to deploy {componentName}: {ex.Message}"; - installMessageIsError = true; - } - finally - { - isDeploying = false; - } - } - - /// - /// Shows the uninstall confirmation modal for the specified component. - /// - private void ConfirmUninstall(string componentName) - { - pendingUninstallComponent = componentName; - } - - /// - /// Sends an uninstall request for the component that the user confirmed. - /// On success, re-scans components to refresh statuses. - /// - private async Task UninstallComponent() - { - if (pendingUninstallComponent is null) - { - return; - } - - isUninstalling = true; - errorMessage = null; - - try - { - UninstallComponentRequest request = new(pendingUninstallComponent); - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/clusters/{ClusterId}/components/uninstall", request); - - if (response.IsSuccessStatusCode) - { - pendingUninstallComponent = null; - - // Re-run adoption to refresh component statuses. - - await RunAdoption(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? $"Failed to uninstall {pendingUninstallComponent}."; - pendingUninstallComponent = null; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to uninstall: {ex.Message}"; - pendingUninstallComponent = null; - } - finally - { - isUninstalling = false; - } - } - - /// - /// Opens the upgrade modal for a component. Fetches available versions - /// from the Helm repository so the user can pick a target version. - /// - private async Task ShowUpgradeModal(string componentName) - { - upgradeTargetComponent = componentName; - selectedComponentUpgradeVersion = string.Empty; - componentUpgradeError = null; - componentAvailableVersions = new(); - isLoadingComponentVersions = true; - - // Find the current version from the adoption report so we can - // display "Currently installed: vX.Y.Z" in the modal. - - ComponentCheckResultDto? component = adoptionReport?.Components - .FirstOrDefault(c => c.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - - upgradeCurrentComponentVersion = component?.Configuration?.Version; - - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/clusters/components/{componentName}/versions"); - - if (response?.Data is not null) - { - componentAvailableVersions = response.Data; - } - } - catch (Exception ex) - { - componentUpgradeError = $"Failed to fetch available versions: {ex.Message}"; - } - finally - { - isLoadingComponentVersions = false; - } - } - - /// - /// Closes the upgrade modal and resets all upgrade-related state. - /// - private void CloseUpgradeModal() - { - upgradeTargetComponent = null; - selectedComponentUpgradeVersion = string.Empty; - componentAvailableVersions = new(); - componentUpgradeError = null; - upgradeCurrentComponentVersion = null; - } - - /// - /// Sends the upgrade request for the selected component and version. - /// On success, re-scans components to show the updated version. - /// - private async Task UpgradeComponent() - { - if (upgradeTargetComponent is null || string.IsNullOrEmpty(selectedComponentUpgradeVersion)) - { - return; - } - - isUpgradingComponent = true; - componentUpgradeError = null; - - try - { - UpgradeComponentRequest request = new(upgradeTargetComponent, selectedComponentUpgradeVersion); - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/clusters/{ClusterId}/components/upgrade", request); - - if (response.IsSuccessStatusCode) - { - CloseUpgradeModal(); - - // Re-run adoption to refresh component statuses and version. - - await RunAdoption(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - componentUpgradeError = errorResponse?.Error ?? $"Failed to upgrade {upgradeTargetComponent}."; - } - } - catch (Exception ex) - { - componentUpgradeError = $"Failed to upgrade: {ex.Message}"; - } - finally - { - isUpgradingComponent = false; - } - } - - /// - /// Selects a component row, expanding its configuration panel. - /// Pre-populates the form with the component's current configuration values. - /// - private void SelectComponent(ComponentCheckResultDto component) - { - if (selectedComponent == component.ComponentName) - { - selectedComponent = null; - return; - } - - selectedComponent = component.ComponentName; - pendingInstallComponent = null; - activeModalComponent = component; - configFormValues = component.Configuration?.Values is not null - ? new Dictionary(component.Configuration.Values) - : new Dictionary(); - - // Pre-populate missing keys with schema defaults so the form shows all parameters. - - ConfigurationSchemaDto? schema = componentSchemas?.FirstOrDefault( - s => s.ComponentName.Equals(component.ComponentName, StringComparison.OrdinalIgnoreCase)); - - if (schema is not null) - { - foreach (ConfigurationParameterDto param in schema.Parameters) - { - if (!configFormValues.ContainsKey(param.Key) && param.DefaultValue is not null) - { - configFormValues[param.Key] = param.DefaultValue; - } - } - } - - newConfigKey = string.Empty; - newConfigValue = string.Empty; - configMessage = null; - } - - private void CloseComponentModal() - { - activeModalComponent = null; - pendingInstallComponent = null; - selectedComponent = null; - installMessage = null; - configMessage = null; - } - - private void UpdateConfigValue(string key, string value) - { - configFormValues[key] = value; - } - - /// - /// When the user selects a PostgreSQL cluster from the dropdown during configure, - /// we store the cluster name AND automatically fill in the cnpgNamespace field. - /// - private void OnPostgresClusterSelectedForConfig(string key, string value) - { - configFormValues[key] = value; - - if (!string.IsNullOrEmpty(value) && pgClusters is not null) - { - PgClusterDto? selected = pgClusters.FirstOrDefault(pg => pg.Name == value); - - if (selected is not null) - { - configFormValues["cnpgNamespace"] = selected.Namespace; - } - } - else - { - configFormValues.Remove("cnpgNamespace"); - } - } - - private string GetConfigValue(string key) - { - return configFormValues.TryGetValue(key, out string? value) ? value : string.Empty; - } - - /// - /// Resolves a configuration key to a human-readable display name using the - /// component's schema. Falls back to the raw key if no schema match is found. - /// - private static string GetConfigDisplayName(string key, ConfigurationSchemaDto? schema) - { - if (schema is not null) - { - ConfigurationParameterDto? param = schema.Parameters.FirstOrDefault( - p => p.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); - - if (param is not null) - { - return param.DisplayName; - } - } - - return key; - } - - private ConfigurationSchemaDto? GetComponentSchema(string componentName) - { - return componentSchemas?.FirstOrDefault( - s => s.ComponentName.Equals(componentName, StringComparison.OrdinalIgnoreCase)); - } - - private static Dictionary GetGlobalConfigValues(Dictionary values) - { - // For components with per-issuer details (letsencrypt), exclude keys that - // duplicate per-issuer data. The per-issuer "Issuers" card shows solver type, - // DNS provider, secret names etc. — showing them again in "Current State" - // is confusing. Only keep the high-level summary keys. - - bool hasPerIssuerData = values.Keys.Any(k => k.StartsWith("issuer.")); - - HashSet perIssuerDuplicates = new(StringComparer.OrdinalIgnoreCase) - { - "solverType", "dnsSolverProvider", "dnsSolverSecretName", "dnsSolverSecretNameAzure", - "dnsSolverSecretNameGcp", "dnsSolverHostedZone", "dnsSolverProject", - "dnsSolverClientId", "dnsSolverSubscriptionId", "dnsSolverTenantId", - "dnsSolverResourceGroup", "dnsZones" - }; - - Dictionary global = new(); - - foreach (KeyValuePair kvp in values) - { - if (kvp.Key.StartsWith("issuer.")) - { - continue; - } - - // Skip detailed DNS solver config from the global summary when - // per-issuer data is available — those details are shown per-issuer. - - if (hasPerIssuerData && perIssuerDuplicates.Contains(kvp.Key)) - { - continue; - } - - global[kvp.Key] = kvp.Value; - } - - return global; - } - - private static Dictionary> GetPerIssuerConfigValues(Dictionary values) - { - Dictionary> perIssuer = new(); - - foreach (KeyValuePair kvp in values) - { - if (kvp.Key.StartsWith("issuer.")) - { - string[] parts = kvp.Key.Split('.', 3); - - if (parts.Length == 3) - { - string issuerName = parts[1]; - - if (!perIssuer.ContainsKey(issuerName)) - { - perIssuer[issuerName] = new(); - } - - perIssuer[issuerName][parts[2]] = kvp.Value; - } - } - } - - return perIssuer; - } - - /// - /// Checks whether a schema parameter should be visible based on its DependsOn - /// condition. If DependsOnKey is set, the parameter is only visible when the - /// referenced parameter's current value matches one of DependsOnValues. - /// This enables provider-specific fields (e.g., show Azure DNS fields only when - /// dnsSolverProvider is "azuredns"). - /// - private bool IsParameterVisible(ConfigurationParameterDto param, Func getValue) - { - if (param.DependsOnKey is null || param.DependsOnValues is null) - { - return true; - } - - string currentValue = getValue(param.DependsOnKey); - - return param.DependsOnValues.Contains(currentValue, StringComparer.OrdinalIgnoreCase); - } - - /// - /// Converts a per-issuer field key (e.g., "solverType", "dnsSolverProvider") - /// into a human-readable label for display in the per-issuer detail cards. - /// - private static string GetIssuerFieldDisplayName(string fieldKey) - { - return fieldKey switch - { - "solverType" => "Solver Type", - "acmeServer" => "ACME Server", - "email" => "Email", - "dnsSolverProvider" => "DNS Provider", - "dnsSolverSecretName" => "DNS Secret", - "dnsSolverHostedZone" => "Hosted Zone", - "dnsSolverProject" => "GCP Project", - "dnsSolverClientId" => "Azure Client ID", - "dnsSolverSubscriptionId" => "Azure Subscription", - "dnsSolverTenantId" => "Azure Tenant", - "dnsSolverResourceGroup" => "Azure Resource Group", - "dnsZones" => "DNS Zones", - _ => fieldKey - }; - } - - private void UpdateBoolConfigValue(string key, ChangeEventArgs e) - { - string value = e.Value is true ? "true" : "false"; - configFormValues[key] = value; - } - - private void RemoveConfigKey(string key) - { - configFormValues.Remove(key); - } - - private void AddConfigKey() - { - if (!string.IsNullOrWhiteSpace(newConfigKey) && !configFormValues.ContainsKey(newConfigKey)) - { - configFormValues[newConfigKey] = newConfigValue; - newConfigKey = string.Empty; - newConfigValue = string.Empty; - } - } - - /// - /// Sends the updated configuration to the backend via the configure endpoint. - /// - private async Task SaveConfiguration(string componentName) - { - isConfiguring = true; - configMessage = null; - - try - { - ConfigureComponentRequest request = new(Values: configFormValues); - HttpResponseMessage response = await Http.PutAsJsonAsync( - $"api/clusters/{ClusterId}/components/{componentName}/configure", request); - - if (response.IsSuccessStatusCode) - { - activeModalComponent = null; - selectedComponent = null; - configMessage = null; - - // Re-run adoption to show updated state. - - await RunAdoption(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - configMessage = errorResponse?.Error ?? "Failed to save configuration."; - } - } - catch (Exception ex) - { - configMessage = $"Error: {ex.Message}"; - } - finally - { - isConfiguring = false; - } - } - - private void ShowProvisionForm() - { - showProvisionServiceForm = true; - newService = new ServiceFormModel(); - } - - private async Task ProvisionService() - { - if (string.IsNullOrWhiteSpace(newService.ServiceType) || string.IsNullOrWhiteSpace(newService.Name)) - { - errorMessage = "Service type and name are required."; - return; - } - - isProvisioning = true; - errorMessage = null; - - try - { - ProvisionServiceRequest request = new(ClusterId, newService.ServiceType, newService.Name, newService.Namespace ?? "default"); - HttpResponseMessage response = await Http.PostAsJsonAsync("api/services", request); - - if (response.IsSuccessStatusCode) - { - showProvisionServiceForm = false; - await LoadServices(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to provision service."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to provision service: {ex.Message}"; - } - finally - { - isProvisioning = false; - } - } - - private async Task DecommissionService(Guid serviceId) - { - try - { - HttpResponseMessage response = await Http.PostAsync($"api/services/{serviceId}/decommission", null); - - if (response.IsSuccessStatusCode) - { - await LoadServices(); - } - else - { - errorMessage = "Failed to decommission service."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to decommission service: {ex.Message}"; - } - } - - // ─── MinIO Tenant Methods ───────────────────────────────────────────── - - private async Task LoadMinioTenants() - { - try - { - List? all = - await Http.GetFromJsonAsync>("api/minio-tenants"); - - minioTenants = all?.Where(t => t.ClusterId == ClusterId).ToList() ?? new List(); - - // If no tenants were found in the repository, automatically - // discover existing MinIO tenants. This handles the case where - // the Provisioning service restarted (in-memory repo is empty) - // or when the user opens the storage tab for the first time. - - if (minioTenants.Count == 0 && !isAdoptingMinioTenants) - { - await AdoptMinioTenants(); - } - } - catch - { - minioTenants = new List(); - } - } - - /// - /// Discovers and adopts existing MinIO tenants running on the Kubernetes - /// cluster. Same pattern as CNPG cluster adoption — the Provisioning service - /// scans for Tenant CRDs and imports them. - /// - private async Task AdoptMinioTenants() - { - if (cluster is null) - { - return; - } - - isAdoptingMinioTenants = true; - minioMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/minio-tenants/adopt", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - ApiResponse>? result = await response.Content.ReadFromJsonAsync>>(); - int count = result?.Data?.Count ?? 0; - - if (count > 0) - { - minioMessage = $"Discovered and adopted {count} MinIO tenant(s)."; - minioMessageIsError = false; - await LoadMinioTenants(); - } - } - else - { - string error = await response.Content.ReadAsStringAsync(); - minioMessage = $"Failed to discover tenants: {error}"; - minioMessageIsError = true; - } - } - catch (Exception ex) - { - minioMessage = $"Error: {ex.Message}"; - minioMessageIsError = true; - } - finally - { - isAdoptingMinioTenants = false; - StateHasChanged(); - } - } - - /// - /// Expands or collapses a MinIO tenant card. When expanding, loads live - /// health data and bucket list from the cluster. - /// - private async Task ToggleMinioTenantExpand(MinioTenantSummaryDto mt) - { - if (expandedMinioTenantId == mt.Id) - { - expandedMinioTenantId = null; - minioTenantHealth = null; - minioTenantBuckets = null; - minioBucketError = null; - } - else - { - expandedMinioTenantId = mt.Id; - - await Task.WhenAll( - LoadMinioTenantHealth(mt.Id), - LoadMinioTenantBuckets(mt.Id)); - } - } - - private async Task LoadMinioTenantHealth(Guid tenantId) - { - try - { - HttpResponseMessage httpResponse = await Http.GetAsync( - $"api/minio-tenants/{tenantId}/health?clusterId={ClusterId}"); - - if (httpResponse.IsSuccessStatusCode) - { - ApiResponse? response = - await httpResponse.Content.ReadFromJsonAsync>(); - - if (response?.Success == true) - { - minioTenantHealth = response.Data; - - // The health endpoint reconciles the persisted domain status. - // Update the local tenant summary so the card badge reflects - // the current state without requiring a full page reload. - - if (minioTenants is not null && minioTenantHealth is not null) - { - string newStatus = minioTenantHealth.CurrentState == "Initialized" ? "Running" : minioTenantHealth.CurrentState; - int index = minioTenants.FindIndex(t => t.Id == tenantId); - - if (index >= 0 && minioTenants[index].Status != newStatus) - { - MinioTenantSummaryDto old = minioTenants[index]; - minioTenants[index] = old with { Status = newStatus }; - } - } - } - } - } - catch - { - minioTenantHealth = null; - } - - StateHasChanged(); - } - - private async Task LoadMinioTenantBuckets(Guid tenantId) - { - minioBucketError = null; - - try - { - HttpResponseMessage httpResponse = await Http.GetAsync( - $"api/minio-tenants/{tenantId}/buckets?clusterId={ClusterId}"); - - if (httpResponse.IsSuccessStatusCode) - { - ApiResponse>? response = - await httpResponse.Content.ReadFromJsonAsync>>(); - - if (response?.Success == true) - { - minioTenantBuckets = response.Data ?? new List(); - } - else - { - minioBucketError = response?.Error ?? "Failed to list buckets."; - minioTenantBuckets = new List(); - } - } - else - { - minioBucketError = $"Bucket request failed ({httpResponse.StatusCode})."; - minioTenantBuckets = new List(); - } - } - catch (Exception ex) - { - minioBucketError = $"Error loading buckets: {ex.Message}"; - minioTenantBuckets = new List(); - } - - StateHasChanged(); - } - - private async Task CreateMinioBucket(Guid tenantId) - { - if (string.IsNullOrWhiteSpace(minioNewBucketName)) - { - return; - } - - isCreatingMinioBucket = true; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/minio-tenants/{tenantId}/buckets/create", - new { ClusterId, BucketName = minioNewBucketName.Trim() }); - - if (response.IsSuccessStatusCode) - { - minioNewBucketName = string.Empty; - await LoadMinioTenantBuckets(tenantId); - } - else - { - minioMessage = "Failed to create bucket."; - minioMessageIsError = true; - } - } - catch (Exception ex) - { - minioMessage = $"Error creating bucket: {ex.Message}"; - minioMessageIsError = true; - } - finally - { - isCreatingMinioBucket = false; - StateHasChanged(); - } - } - - private async Task DeleteMinioBucket(Guid tenantId, string bucketName) - { - isDeletingMinioBucket = true; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/minio-tenants/{tenantId}/buckets/delete", - new { ClusterId, BucketName = bucketName }); - - if (response.IsSuccessStatusCode) - { - await LoadMinioTenantBuckets(tenantId); - } - else - { - minioMessage = $"Failed to delete bucket '{bucketName}'."; - minioMessageIsError = true; - } - } - catch (Exception ex) - { - minioMessage = $"Error deleting bucket: {ex.Message}"; - minioMessageIsError = true; - } - finally - { - isDeletingMinioBucket = false; - StateHasChanged(); - } - } - - // ─── Cleura S3 Storage Methods ──────────────────────────────────────── - - private async Task LoadStorageInfo() - { - isLoadingStorage = true; - StateHasChanged(); - - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/clusters/{ClusterId}/storage/info"); - - if (response is not null && response.Success) - { - storageInfo = response.Data; - - // If S3 is available, also load existing credentials. - - if (storageInfo?.Available == true) - { - await LoadS3Credentials(); - } - } - else - { - storageInfo = new StorageInfoDto(false, null, null, response?.Error ?? "Failed to load storage info."); - } - } - catch - { - storageInfo = new StorageInfoDto(false, null, null, "Failed to load storage info."); - } - finally - { - isLoadingStorage = false; - StateHasChanged(); - } - } - - private async Task LoadS3Credentials() - { - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/clusters/{ClusterId}/storage/credentials"); - - if (response is not null && response.Success) - { - s3Credentials = response.Data; - } - } - catch - { - s3Credentials = null; - } - } - - private async Task CreateS3Credential() - { - isCreatingS3Credential = true; - storageMessage = null; - newS3Credential = null; - - try - { - HttpResponseMessage response = await Http.PostAsync( - $"api/clusters/{ClusterId}/storage/credentials", - new StringContent("", System.Text.Encoding.UTF8, "application/json")); - - if (response.IsSuccessStatusCode) - { - ApiResponse? result = - await response.Content.ReadFromJsonAsync>(); - - if (result?.Data is not null) - { - newS3Credential = result.Data; - storageMessage = "Credentials created. Copy the secret key now — it won't be shown again."; - storageMessageIsError = false; - await LoadS3Credentials(); - } - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - storageMessage = errorResponse?.Error ?? "Failed to create S3 credentials."; - storageMessageIsError = true; - } - } - catch (Exception ex) - { - storageMessage = $"Error: {ex.Message}"; - storageMessageIsError = true; - } - finally - { - isCreatingS3Credential = false; - } - } - - private async Task DeleteS3Credential(string accessKey) - { - isDeletingS3Credential = true; - storageMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync($"api/clusters/{ClusterId}/storage/credentials/{accessKey}"); - - if (response.IsSuccessStatusCode) - { - storageMessage = $"Credential {accessKey[..8]}... revoked."; - storageMessageIsError = false; - newS3Credential = null; - await LoadS3Credentials(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - storageMessage = errorResponse?.Error ?? "Failed to revoke credential."; - storageMessageIsError = true; - } - } - catch (Exception ex) - { - storageMessage = $"Error: {ex.Message}"; - storageMessageIsError = true; - } - finally - { - isDeletingS3Credential = false; - } - } - - private void ShowMinioCreateForm() - { - showMinioCreateForm = true; - newMinioTenant = new MinioCreateFormModel(); - newMinioTenant.Pools.Add(new MinioPoolFormModel()); - } - - private async Task LoadBuckets() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters/{ClusterId}/storage/buckets"); - - if (response is not null && response.Success) - { - storageBuckets = response.Data ?? new(); - } - } - catch - { - storageBuckets = new(); - } - } - - private async Task CreateBucket() - { - if (string.IsNullOrWhiteSpace(newBucketName)) - { - bucketMessage = "Bucket name is required."; - bucketMessageIsError = true; - return; - } - - isCreatingBucket = true; - bucketMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/clusters/{ClusterId}/storage/buckets", - new { BucketName = newBucketName.Trim().ToLowerInvariant(), Encrypted = newBucketEncrypted }); - - if (response.IsSuccessStatusCode) - { - bucketMessage = $"Bucket '{newBucketName}' created successfully."; - bucketMessageIsError = false; - showBucketCreateForm = false; - newBucketName = string.Empty; - newBucketEncrypted = true; - await LoadBuckets(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - bucketMessage = errorResponse?.Error ?? "Failed to create bucket."; - bucketMessageIsError = true; - } - } - catch (Exception ex) - { - bucketMessage = $"Error: {ex.Message}"; - bucketMessageIsError = true; - } - finally - { - isCreatingBucket = false; - } - } - - private async Task ToggleBucketDetails(Guid bucketId) - { - if (expandedBucketId == bucketId) - { - expandedBucketId = null; - expandedBucketDetail = null; - return; - } - - expandedBucketId = bucketId; - - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>( - $"api/clusters/{ClusterId}/storage/buckets/{bucketId}"); - - if (response is not null && response.Success) - { - expandedBucketDetail = response.Data; - } - } - catch - { - expandedBucketDetail = null; - } - } - - private async Task DeleteBucket(Guid bucketId) - { - isDeletingBucket = true; - bucketMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync($"api/clusters/{ClusterId}/storage/buckets/{bucketId}"); - - if (response.IsSuccessStatusCode) - { - bucketMessage = "Bucket deleted."; - bucketMessageIsError = false; - expandedBucketId = null; - expandedBucketDetail = null; - await LoadBuckets(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - bucketMessage = errorResponse?.Error ?? "Failed to delete bucket."; - bucketMessageIsError = true; - } - } - catch (Exception ex) - { - bucketMessage = $"Error: {ex.Message}"; - bucketMessageIsError = true; - } - finally - { - isDeletingBucket = false; - } - } - - private async Task CreateMinioTenant() - { - if (string.IsNullOrWhiteSpace(newMinioTenant.Name) || string.IsNullOrWhiteSpace(newMinioTenant.Namespace)) - { - errorMessage = "Tenant name and namespace are required."; - return; - } - - if (newMinioTenant.Pools.Count == 0) - { - errorMessage = "At least one pool is required."; - return; - } - - isCreatingMinio = true; - errorMessage = null; - - try - { - object request = new - { - ClusterId, - newMinioTenant.Name, - newMinioTenant.Namespace, - Pools = newMinioTenant.Pools.Select(p => new - { - p.Servers, - p.VolumesPerServer, - p.StorageSize, - p.StorageClass - }).ToList() - }; - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/minio-tenants", request); - - if (response.IsSuccessStatusCode) - { - showMinioCreateForm = false; - await LoadMinioTenants(); - } - else - { - errorMessage = "Failed to create MinIO tenant."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to create MinIO tenant: {ex.Message}"; - } - finally - { - isCreatingMinio = false; - } - } - - private async Task ShowMinioConfigureForm(MinioTenantSummaryDto mt) - { - configuringMinioTenant = mt; - - // Fetch the tenant detail which includes per-pool configuration. - // This gives us the actual servers, volumes, storage size, and storage class - // for each pool — not derived estimates from health data. - - try - { - MinioTenantDetailDto? detail = await Http.GetFromJsonAsync( - $"api/minio-tenants/{mt.Id}"); - - if (detail?.Pools is not null && detail.Pools.Count > 0) - { - minioConfigurePools = detail.Pools.Select(p => new MinioPoolFormModel - { - Servers = p.Servers, - VolumesPerServer = p.VolumesPerServer, - StorageSize = p.StorageSize, - StorageClass = p.StorageClass, - CpuRequest = p.CpuRequest, - CpuLimit = p.CpuLimit, - MemoryRequest = p.MemoryRequest, - MemoryLimit = p.MemoryLimit - }).ToList(); - - return; - } - } - catch - { - // Fall through to estimation from summary data. - } - - // Fallback: estimate a single pool from the summary data. - - minioConfigurePools = new List - { - new() - { - Servers = mt.TotalServers, - VolumesPerServer = 4, - StorageSize = mt.TotalCapacity, - StorageClass = "ceph-block" - } - }; - } - - private async Task SaveMinioConfiguration() - { - if (configuringMinioTenant is null || minioConfigurePools.Count == 0) - { - return; - } - - isConfiguringMinio = true; - errorMessage = null; - - try - { - object request = new - { - ClusterId, - Pools = minioConfigurePools.Select(p => new - { - p.Servers, - p.VolumesPerServer, - p.StorageSize, - p.StorageClass, - p.CpuRequest, - p.CpuLimit, - p.MemoryRequest, - p.MemoryLimit - }).ToList() - }; - - HttpResponseMessage response = await Http.PutAsJsonAsync( - $"api/minio-tenants/{configuringMinioTenant.Id}/configure", request); - - if (response.IsSuccessStatusCode) - { - configuringMinioTenant = null; - await LoadMinioTenants(); - } - else - { - errorMessage = "Failed to update MinIO tenant configuration."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to update MinIO tenant: {ex.Message}"; - } - finally - { - isConfiguringMinio = false; - } - } - - private async Task DeleteMinioTenant(MinioTenantSummaryDto mt) - { - errorMessage = null; - - try - { - HttpRequestMessage request = new(HttpMethod.Delete, $"api/minio-tenants/{mt.Id}") - { - Content = JsonContent.Create(new { ClusterId }) - }; - - HttpResponseMessage response = await Http.SendAsync(request); - - if (response.IsSuccessStatusCode) - { - await LoadMinioTenants(); - } - else - { - errorMessage = "Failed to delete MinIO tenant."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to delete MinIO tenant: {ex.Message}"; - } - } - - // ─── Provider Methods ───────────────────────────────────────────────── - - private void EditProvider() - { - showProviderForm = true; - providerForm.Provider = cluster?.Provider ?? "Cleura"; - providerForm.Username = cluster?.ProviderUsername ?? ""; - providerForm.Region = cluster?.ProviderRegion ?? ""; - } - - private async Task SaveProvider() - { - isSavingProvider = true; - providerTestResult = null; - - try - { - SetProviderRequestDto body = new( - providerForm.Provider, - providerForm.Username, - providerForm.Password, - providerForm.Region, - string.IsNullOrWhiteSpace(providerForm.OpenStackAuthUrl) ? null : providerForm.OpenStackAuthUrl, - string.IsNullOrWhiteSpace(providerForm.OpenStackProjectId) ? null : providerForm.OpenStackProjectId, - string.IsNullOrWhiteSpace(providerForm.OpenStackUsername) ? null : providerForm.OpenStackUsername, - string.IsNullOrWhiteSpace(providerForm.OpenStackPassword) ? null : providerForm.OpenStackPassword, - string.IsNullOrWhiteSpace(providerForm.OpenStackUserDomainName) ? null : providerForm.OpenStackUserDomainName); - - HttpResponseMessage response = await Http.PutAsJsonAsync($"api/clusters/{ClusterId}/provider", body); - - if (response.IsSuccessStatusCode) - { - showProviderForm = false; - await LoadCluster(); - } - else - { - errorMessage = "Failed to save provider."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to save provider: {ex.Message}"; - } - finally - { - isSavingProvider = false; - } - } - - private async Task UnlinkProvider() - { - isSavingProvider = true; - providerTestResult = null; - - try - { - SetProviderRequestDto body = new("None", null, null, null); - HttpResponseMessage response = await Http.PutAsJsonAsync($"api/clusters/{ClusterId}/provider", body); - - if (response.IsSuccessStatusCode) - { - await LoadCluster(); - } - else - { - errorMessage = "Failed to unlink provider."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to unlink provider: {ex.Message}"; - } - finally - { - isSavingProvider = false; - } - } - - private async Task TestProviderConnection() - { - isTestingProvider = true; - providerTestResult = null; - showTwoFactorInput = false; - - try - { - ApiResponse? response = - await Http.PostAsync($"api/clusters/{ClusterId}/provider/test", null) - .ContinueWith(async t => - { - HttpResponseMessage r = await t; - return await r.Content.ReadFromJsonAsync>(); - }).Unwrap(); - - if (response is not null && response.Success) - { - providerTestResult = response.Data; - - // If the test returned a 2FA prompt with a verification token, show the SMS code input. - - if (providerTestResult is { Success: false, Verification: not null }) - { - verificationToken = providerTestResult.Verification; - showTwoFactorInput = true; - } - } - else - { - providerTestResult = new ProviderTestResultDto(false, response?.Error ?? "Failed to test connection.", null); - } - } - catch (Exception ex) - { - providerTestResult = new ProviderTestResultDto(false, $"Error: {ex.Message}", null); - } - finally - { - isTestingProvider = false; - } - } - - private async Task SubmitTwoFactorCode() - { - if (string.IsNullOrWhiteSpace(twoFactorCode)) - { - return; - } - - isTestingProvider = true; - providerTestResult = null; - - try - { - // Retry the test with the SMS code included in the body. - - HttpResponseMessage httpResponse = await Http.PostAsJsonAsync( - $"api/clusters/{ClusterId}/provider/test", - new { TwoFactorCode = twoFactorCode.Trim(), Verification = verificationToken }); - - ApiResponse? response = - await httpResponse.Content.ReadFromJsonAsync>(); - - if (response is not null && response.Success) - { - providerTestResult = response.Data; - showTwoFactorInput = false; - twoFactorCode = string.Empty; - } - else - { - providerTestResult = new ProviderTestResultDto(false, response?.Error ?? "Verification failed.", null); - } - } - catch (Exception ex) - { - providerTestResult = new ProviderTestResultDto(false, $"Error: {ex.Message}", null); - } - finally - { - isTestingProvider = false; - } - } - - private void CancelTwoFactor() - { - showTwoFactorInput = false; - twoFactorCode = string.Empty; - verificationToken = null; - providerTestResult = null; - } - - private static string CalculateMinioCapacity(List pools) - { - long totalGi = 0; - - foreach (MinioPoolFormModel pool in pools) - { - if (long.TryParse(pool.StorageSize.Replace("Gi", "").Replace("Ti", ""), out long size)) - { - long multiplier = pool.StorageSize.Contains("Ti") ? 1024 : 1; - totalGi += pool.Servers * pool.VolumesPerServer * size * multiplier; - } - } - - return totalGi >= 1024 ? $"{totalGi / 1024.0:F1} TiB ({totalGi} GiB)" : $"{totalGi} GiB"; - } - - private static string GetMinioBadgeClass(string status) => status switch - { - "Running" => "bg-success", - "Provisioning" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Decommissioned" => "bg-secondary", - _ => "bg-secondary" - }; - - private static string GetMinioCardBorder(string status) => status switch - { - "Degraded" => "border-danger", - "Provisioning" => "border-warning", - _ => "" - }; - - private static string GetStatusBadgeClass(string status) => status switch - { - "Connected" => "bg-success", - "Pending" => "bg-warning text-dark", - "Unreachable" => "bg-danger", - _ => "bg-secondary" - }; - - private static string GetReadinessBadgeClass(string status) => status switch - { - "Ready" => "bg-success", - "PartiallyReady" => "bg-warning text-dark", - "NotReady" => "bg-danger", - _ => "bg-secondary" - }; - - private static string GetComponentBadgeClass(string status) => status switch - { - "Installed" => "bg-success", - "Degraded" => "bg-warning text-dark", - "NotInstalled" => "bg-danger", - _ => "bg-secondary" - }; - - private static string GetServiceStateBadgeClass(string state) => state switch - { - "Running" => "bg-success", - "Pending" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Decommissioned" => "bg-secondary", - _ => "bg-info" - }; - - // ─── DTOs ───────────────────────────────────────────────────────────── - - private record ApiResponse - { - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - } - - private record ClusterDetailDto( - Guid Id, - Guid TenantId, - string Name, - string ApiServerUrl, - string Status, - string? Provider, - string? ProviderUsername, - string? ProviderRegion, - bool HasOpenStackCredentials, - string? OpenStackAuthUrl, - string? OpenStackUsername, - DateTimeOffset? LastHealthCheckAt, - List? Components); - - private record AdoptionReportDto(string Status, List Components); - - private record ClusterComponentDto( - string ComponentName, - string Status, - string? Version, - string? Namespace, - string? HelmReleaseName, - Dictionary? Configuration, - DateTimeOffset? LastCheckedAt); - - private record ComponentCheckResultDto( - string ComponentName, - string Status, - List Details, - List MissingItems, - DiscoveredConfigurationDto? Configuration); - - private record DiscoveredConfigurationDto( - string? Version, - string? Namespace, - string? HelmReleaseName, - Dictionary? Values); - - private record ServiceDto( - Guid Id, - string Name, - string ServiceType, - string Namespace, - string CurrentState, - string DesiredState); - - private record DeployComponentRequest(string ComponentName, string? Version = null, string? Namespace = null, Dictionary? Parameters = null); - - private record UninstallComponentRequest(string ComponentName, string? Namespace = null, Dictionary? Parameters = null); - - private record UpgradeComponentRequest(string ComponentName, string Version); - - private record ConfigureComponentRequest(string? Namespace = null, Dictionary? Values = null); - - private record ProvisionServiceRequest(Guid ClusterId, string ServiceType, string Name, string Namespace); - - private class ServiceFormModel - { - public string ServiceType { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string? Namespace { get; set; } - } - - private record ConfigurationSchemaDto( - string ComponentName, - string DisplayName, - string Description, - List Parameters); - - private record ConfigurationParameterDto( - string Key, - string DisplayName, - string Description, - string Type, - string Group, - bool IsRequired, - string? DefaultValue, - int? Min, - int? Max, - List? AllowedValues, - string? DependsOnKey = null, - List? DependsOnValues = null); - - private record MinioTenantSummaryDto( - Guid Id, - Guid ClusterId, - string Name, - string Namespace, - string Endpoint, - string ConsoleEndpoint, - string Status, - string TotalCapacity, - int PoolCount, - int TotalServers, - DateTimeOffset CreatedAt); - - private record MinioTenantDetailDto( - Guid Id, - Guid ClusterId, - string Name, - string Namespace, - string Endpoint, - string ConsoleEndpoint, - string CredentialsSecret, - string Status, - string? StatusMessage, - string TotalCapacity, - List Pools, - DateTimeOffset CreatedAt, - DateTimeOffset? LastReconcileAt); - - private record MinioPoolDetailDto( - int Servers, - int VolumesPerServer, - string StorageSize, - string StorageClass, - string? CpuRequest, - string? CpuLimit, - string? MemoryRequest, - string? MemoryLimit); - - private record MinioTenantHealthDto( - string CurrentState, - int ReadyPods, - int TotalPods, - string? TotalCapacity, - string? UsedCapacity, - int DriveCount, - int DrivesOnline, - int DrivesOffline, - List Buckets); - - private record StorageInfoDto(bool Available, string? S3Endpoint, string? Region, string? Message); - - private record S3CredentialResultDto(string AccessKey, string SecretKey, string S3Endpoint, string Region, string ProjectId); - - private record S3CredentialSummaryDto(string AccessKey, string S3Endpoint, string Region, string ProjectId); - - private record S3CredentialListDto(string S3Endpoint, string Region, List Credentials); - - private record StorageBucketSummaryDto(Guid Id, string Name, string Endpoint, string Region, bool Encrypted, DateTimeOffset CreatedAt); - - private record StorageBucketDetailDto(Guid Id, string Name, string AccessKey, string SecretKey, string Endpoint, string Region, bool Encrypted, DateTimeOffset CreatedAt); - - private record PgClusterDto( - Guid Id, Guid ClusterId, string Name, string Namespace, - string PostgresVersion, int Instances, string StorageSize, - string Status, List Databases, - string? BackupSchedule, int? BackupRetentionDays, - string? TargetVersion); - - private record PgClusterHealthDto( - string Phase, int ReadyInstances, int TotalInstances, - string? CurrentPrimary, string? CurrentPrimaryTimestamp, - string? TargetPrimary, int CurrentTimeline, - string? LatestWalArchived, string? FirstRecoverabilityPoint, - string? LastSuccessfulBackup, string? StorageUsed, string? WalStorageUsed, - List Instances, PgReplicationDto? Replication); - - private record PgInstanceDto( - string Name, string Role, string Status, bool Ready, - string? CurrentLsn, string? ReceivedLsn, string? ReplayedLsn, - long? ReplicationLagBytes, string? TimelineId, string? PodPhase); - - private record PgReplicationDto(bool StreamingActive, int SyncReplicas, long MaxLagBytes); - - private record PgBackupDto( - string Name, string Phase, string? Method, - DateTimeOffset? StartedAt, DateTimeOffset? CompletedAt, - string? BackupSize, string? WalSize, string? DestinationPath, - string? Error, int? TimelineId, string? BeginLsn, string? EndLsn); - - private record BackupDiagnosticsDto( - string ClusterName, string Namespace, - string? FirstRecoverabilityPoint, string? LastSuccessfulBackup, - int TotalBackupCRsInNamespace, int TotalBackupCRsClusterWide, - List AllBackupCRs, - List ScheduledBackups); - - private record BackupCRSummaryDto( - string Name, string? Namespace, string? Phase, - string? ClusterRef, string? Method, - DateTimeOffset? StartedAt, DateTimeOffset? CompletedAt); - - private record ScheduledBackupSummaryDto( - string Name, string? Namespace, string? Schedule, - string? ClusterRef, string? LastScheduleTime); - - private record RestorePreviewDto( - string SourceClusterName, string SourceNamespace, - string PostgresVersion, int Instances, string StorageSize, - string RestoredClusterName, string? BackupName, - DateTimeOffset? TargetTime, string RecoveryMethod, - string MinioEndpoint, string MinioBucket, string DestinationPath, - string? FirstRecoverabilityPoint, string? LastSuccessfulBackup, - List Warnings); - - // ─── Redis DTOs ─────────────────────────────────────────────────────── - - private record RedisClusterDto( - Guid Id, Guid ClusterId, string Name, string Namespace, - string RedisVersion, int Replicas, string StorageSize, - bool SentinelEnabled, string Status, string? TargetVersion); - - private record RedisHealthDto( - string Phase, int ReadyReplicas, int DesiredReplicas, - string? MasterNode, string? UsedMemory, bool SentinelActive, - long? UptimeSeconds, List Replicas); - - private record RedisReplicaDto( - string Name, string Role, bool Ready, - int? ConnectedClients, string? UsedMemory, long? ReplicationOffset); - - // ─── MongoDB DTOs ───────────────────────────────────────────────────── - - private record MongoClusterDto( - Guid Id, Guid ClusterId, string Name, string Namespace, - string MongoVersion, int Members, string StorageSize, - string Status, List Databases, - string? BackupSchedule, int? BackupRetentionDays, - string? TargetVersion); - - private record MongoHealthDto( - string Phase, int ReadyMembers, int TotalMembers, - string? CurrentPrimary, long OplogWindowHours, - string? LastBackup, List MemberDetails, - MongoReplicationDto? Replication); - - private record MongoMemberDto( - string Name, string Role, string State, bool Ready, - long? ReplicationLagSeconds, string? PodPhase); - - private record MongoReplicationDto( - bool ReplicationActive, int SyncMembers, long MaxLagSeconds); - - private record CaDto( - string Name, string Type, string SecretName, - List Domains, bool IsExternal, bool InTrustBundle, - string Status, DateTimeOffset? NotBefore, DateTimeOffset? NotAfter, - string? Organization, string? Subject, string? IssuerDN, - string? SerialNumber, string? Thumbprint); - - private record CaOperationResultDto(bool Success, string Message, List Actions); - - private record ClusterSettingsDto(List AllowedRegistries); - - private record HarborProjectListDto(string Name, string Visibility, bool IsProxyCache); - - private record GiteaInfoDto( - string? Version, int Replicas, bool ActionsEnabled, - List Runners); - - private record GiteaRunnerDto( - string Name, string Phase, bool Ready, int RestartCount); - - private record CertificateDto( - string Name, string SecretName, string Namespace, - List Domains, DateTimeOffset? NotBefore, DateTimeOffset? NotAfter, - string? Organization, string? Subject, string? IssuerDN, - string? SerialNumber, string? Thumbprint, - string SecretType, bool IsWildcard, bool InTrustBundle); - - private class MinioCreateFormModel - { - public string Name { get; set; } = string.Empty; - public string Namespace { get; set; } = string.Empty; - public List Pools { get; set; } = new(); - } - - private class MinioPoolFormModel - { - public int Servers { get; set; } = 4; - public int VolumesPerServer { get; set; } = 4; - public string StorageSize { get; set; } = "100Gi"; - public string StorageClass { get; set; } = "ceph-block"; - public string? CpuRequest { get; set; } - public string? CpuLimit { get; set; } - public string? MemoryRequest { get; set; } - public string? MemoryLimit { get; set; } - } - - private class ProviderFormModel - { - public string Provider { get; set; } = "Cleura"; - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - public string Region { get; set; } = string.Empty; - public string OpenStackAuthUrl { get; set; } = string.Empty; - public string OpenStackProjectId { get; set; } = string.Empty; - public string OpenStackUsername { get; set; } = string.Empty; - public string OpenStackPassword { get; set; } = string.Empty; - public string OpenStackUserDomainName { get; set; } = string.Empty; - } - - private record SetProviderRequestDto( - string Provider, - string? Username, - string? Password, - string? Region, - string? OpenStackAuthUrl = null, - string? OpenStackProjectId = null, - string? OpenStackUsername = null, - string? OpenStackPassword = null, - string? OpenStackUserDomainName = null); - - private record ProviderTestResultDto(bool Success, string Message, List? AvailableRegions, string? Verification = null); - - // ─── Keycloak DTOs ──────────────────────────────────────────────────── - - private record KeycloakRealmDto(Guid Id, Guid ClusterId, string Name, string Status, - RealmBrandingDto Branding, RealmPagesDto Pages, - List IdentityProviders, List Organizations); - - private record RealmBrandingDto(string? LogoUrl, string? BackgroundImageUrl, string? BackgroundColor, string? PrimaryColor, string? SecondaryColor, string? TextColor); - private record RealmPagesDto(bool LoginEnabled, bool ProfileEnabled, bool RegistrationEnabled, bool ForgotPasswordEnabled); - private record IdentityProviderDto(string Alias, string DisplayName, string Type, bool Enabled, string? AuthorizationUrl, string? TokenUrl, string? ClientId, string? ClientSecret, string? MetadataUrl); - private record OrganizationDto(string Name, string? Description, List Children); - - // ─── Keycloak form models ───────────────────────────────────────────── - - private class RealmBrandingForm - { - public string? LogoUrl { get; set; } - public string? BackgroundImageUrl { get; set; } - public string? BackgroundColor { get; set; } = "#ffffff"; - public string? PrimaryColor { get; set; } = "#0d6efd"; - public string? SecondaryColor { get; set; } = "#6c757d"; - public string? TextColor { get; set; } = "#212529"; - } - - private class RealmPagesForm - { - public bool LoginEnabled { get; set; } = true; - public bool ProfileEnabled { get; set; } = true; - public bool RegistrationEnabled { get; set; } - public bool ForgotPasswordEnabled { get; set; } = true; - } - - private class IdentityProviderForm - { - public string Alias { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public string Type { get; set; } = "Oidc"; - public string? ClientId { get; set; } - public string? ClientSecret { get; set; } - public string? AuthorizationUrl { get; set; } - public string? TokenUrl { get; set; } - } - - // ─── Database tab methods ──────────────────────────────────────────── - - /// - /// Loads all PostgreSQL clusters for this Kubernetes cluster from - /// the Provisioning service via the BFF proxy. If no clusters are found - /// in the repository, automatically triggers discovery to detect CNPG - /// clusters that are already running on the K8s cluster. - /// - private async Task LoadPgClusters() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/postgres-clusters?clusterId={ClusterId}"); - - if (response?.Success == true) - { - pgClusters = response.Data ?? new(); - } - else - { - pgClusters = new(); - } - - // If no clusters were found in the repository, automatically - // discover existing CNPG clusters. This handles the case where - // the Provisioning service restarted (in-memory repo is empty) - // or when the user opens the databases tab for the first time. - - if (pgClusters.Count == 0 && !isAdoptingPgClusters) - { - await AdoptPostgresClusters(); - } - } - catch - { - pgClusters = new(); - } - - StateHasChanged(); - } - - /// - /// Expands or collapses a PG cluster card to show details, databases, - /// connection info, and configuration controls. When expanding, loads - /// live health data and backup history from the cluster. - /// - private async Task TogglePgClusterExpand(Guid pgClusterId) - { - if (expandedPgClusterId == pgClusterId) - { - expandedPgClusterId = null; - pgClusterHealth = null; - pgClusterBackups = null; - pgLiveDatabases = null; - } - else - { - expandedPgClusterId = pgClusterId; - - // Pre-populate configuration fields from the expanded cluster. - - PgClusterDto? pgCluster = pgClusters?.FirstOrDefault(c => c.Id == pgClusterId); - - if (pgCluster is not null) - { - pgConfigInstances = pgCluster.Instances; - pgConfigBackupSchedule = pgCluster.BackupSchedule ?? "0 0 * * *"; - pgConfigRetentionDays = pgCluster.BackupRetentionDays ?? 7; - } - - // Load live health, backups, and real database list in parallel. - - await Task.WhenAll( - LoadPgClusterHealth(pgClusterId), - LoadPgClusterBackups(pgClusterId), - LoadLiveDatabases(pgClusterId)); - } - } - - /// - /// Discovers and adopts existing CNPG clusters running on the Kubernetes - /// cluster. Requires kubeConfig and contextName from the cluster record. - /// - private async Task AdoptPostgresClusters() - { - if (cluster is null) - { - return; - } - - isAdoptingPgClusters = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/postgres-clusters/adopt", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - ApiResponse>? result = await response.Content.ReadFromJsonAsync>>(); - int count = result?.Data?.Count ?? 0; - pgMessage = count > 0 ? $"Discovered and adopted {count} PostgreSQL cluster(s)." : "No new CNPG clusters found."; - pgMessageIsError = false; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to discover clusters: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isAdoptingPgClusters = false; - StateHasChanged(); - } - } - - /// - /// Creates a new CNPG PostgreSQL cluster. If a storage bucket is selected, - /// its credentials are passed as the backup target. Otherwise the cluster - /// will need an adopted MinIO instance on the Provisioning side. - /// - private async Task CreatePgCluster() - { - if (cluster is null || string.IsNullOrWhiteSpace(newPgClusterName)) - { - return; - } - - isCreatingPgCluster = true; - pgMessage = null; - StateHasChanged(); - - try - { - // If a storage bucket was selected, resolve its credentials. - - string? minioEndpoint = null; - string? minioBucket = newPgClusterBackupBucket; - string? minioCredentialsSecret = null; - - string? s3Region = null; - - if (!string.IsNullOrEmpty(newPgClusterBucketId) && Guid.TryParse(newPgClusterBucketId, out Guid bucketId)) - { - // Resolve the S3 endpoint and credentials from the selected storage - // bucket. The bucket NAME comes from the form field — the user types - // the actual S3 bucket name they want backups written to. - - StorageBucketSummaryDto? selectedBucket = storageBuckets?.FirstOrDefault(b => b.Id == bucketId); - minioEndpoint = selectedBucket?.Endpoint; - minioCredentialsSecret = "cnpg-backup-creds"; - s3Region = selectedBucket?.Region; - } - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/postgres-clusters", new - { - clusterId = ClusterId, - name = newPgClusterName, - @namespace = newPgClusterNamespace, - postgresVersion = newPgClusterVersion, - instances = newPgClusterInstances, - storageSize = newPgClusterStorageSize, - minioEndpoint = minioEndpoint, - minioBucket = minioBucket, - minioCredentialsSecret = minioCredentialsSecret, - s3Region = s3Region - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = $"PostgreSQL cluster '{newPgClusterName}' creation initiated."; - pgMessageIsError = false; - showCreatePgClusterForm = false; - newPgClusterName = string.Empty; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to create cluster: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isCreatingPgCluster = false; - StateHasChanged(); - } - } - - /// - /// Adds a database and owner role to an existing CNPG PostgreSQL cluster. - /// The Provisioning service creates the database via a K8s Job running psql. - /// - private async Task AddDatabase(Guid pgClusterId) - { - if (cluster is null || string.IsNullOrWhiteSpace(newDbName) || string.IsNullOrWhiteSpace(newDbOwner)) - { - return; - } - - isAddingDatabase = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/postgres-clusters/{pgClusterId}/databases", new - { - clusterId = ClusterId, - databaseName = newDbName, - ownerRole = newDbOwner - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = $"Database '{newDbName}' created successfully."; - pgMessageIsError = false; - newDbName = string.Empty; - newDbOwner = string.Empty; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to create database: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isAddingDatabase = false; - StateHasChanged(); - } - } - - /// - /// Reconfigures a CNPG cluster — scaling instances, changing backup schedule - /// or retention. The Provisioning service patches the CNPG Cluster resource. - /// - private async Task ConfigurePgCluster(Guid pgClusterId) - { - if (cluster is null) - { - return; - } - - isConfiguringPgCluster = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PutAsJsonAsync($"api/postgres-clusters/{pgClusterId}/configure", new - { - clusterId = ClusterId, - instances = pgConfigInstances, - backupSchedule = pgConfigBackupSchedule, - backupRetentionDays = pgConfigRetentionDays - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = "Configuration applied successfully."; - pgMessageIsError = false; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to apply configuration: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isConfiguringPgCluster = false; - StateHasChanged(); - } - } - - /// - /// Builds a CNPG service connection string for clipboard copy. - /// - private static string PgConnectionString(PgClusterDto pgCluster, string suffix) - { - return $"{pgCluster.Name}{suffix}.{pgCluster.Namespace}.svc.cluster.local:5432"; - } - - /// - /// Returns the Bootstrap badge CSS class for a PostgreSQL cluster status. - /// - private static string PgStatusBadgeClass(string status) => status switch - { - "Running" or "Cluster in healthy state" => "bg-success", - "Provisioning" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Upgrading" => "bg-info", - "Decommissioned" => "bg-dark", - _ => "bg-secondary" - }; - - /// - /// Returns the Bootstrap badge CSS class for a backup phase. - /// - private static string BackupStatusBadgeClass(string phase) => phase switch - { - "completed" or "Completed" => "bg-success", - "running" or "Running" or "Started" => "bg-info", - "failed" or "Failed" => "bg-danger", - "pending" or "Pending" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - /// - /// Formats a byte count into a human-readable string (KB, MB, GB). - /// - private static string FormatBytes(long bytes) - { - if (bytes < 1024) - { - return $"{bytes} B"; - } - - if (bytes < 1024 * 1024) - { - return $"{bytes / 1024.0:F1} KB"; - } - - if (bytes < 1024 * 1024 * 1024) - { - return $"{bytes / (1024.0 * 1024.0):F1} MB"; - } - - return $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB"; - } - - /// - /// Copies a string to the user's clipboard via JS interop. - /// - private async Task CopyToClipboard(string text) - { - try - { - await Js.InvokeVoidAsync("navigator.clipboard.writeText", text); - } - catch - { - // Clipboard API may not be available in all contexts. - } - } - - /// - /// Loads live health data for a specific CNPG cluster from the BFF proxy. - /// - private async Task LoadPgClusterHealth(Guid pgClusterId) - { - isLoadingPgHealth = true; - pgClusterHealth = null; - StateHasChanged(); - - try - { - ApiResponse? response = await Http.GetFromJsonAsync>( - $"api/postgres-clusters/{pgClusterId}/health?clusterId={ClusterId}"); - - if (response?.Success == true) - { - pgClusterHealth = response.Data; - } - } - catch - { - // Health data is best-effort — if it fails, the static info shows instead. - } - finally - { - isLoadingPgHealth = false; - StateHasChanged(); - } - } - - /// - /// Loads backup history for a specific CNPG cluster from the BFF proxy. - /// - private async Task LoadPgClusterBackups(Guid pgClusterId) - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/postgres-clusters/{pgClusterId}/backups?clusterId={ClusterId}"); - - if (response?.Success == true) - { - pgClusterBackups = response.Data ?? new(); - backupPage = 0; - } - else - { - pgClusterBackups = new(); - } - } - catch - { - pgClusterBackups = new(); - } - - StateHasChanged(); - } - - /// - /// Loads the live database list directly from the running PostgreSQL cluster - /// by querying pg_database on the primary. This discovers all databases - /// including ones created via SQL or migrations that the CR spec doesn't know about. - /// - private async Task LoadLiveDatabases(Guid pgClusterId) - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/postgres-clusters/{pgClusterId}/databases/live?clusterId={ClusterId}"); - - if (response?.Success == true && response.Data is not null && response.Data.Count > 0) - { - pgLiveDatabases = response.Data; - } - else - { - pgLiveDatabases = null; - } - } - catch - { - pgLiveDatabases = null; - } - - StateHasChanged(); - } - - /// - /// Calls the diagnostic endpoint to see raw, unfiltered backup data from K8s. - /// This helps debug why backups might not be showing up in the normal listing. - /// - private async Task DiagnoseBackups(Guid pgClusterId) - { - isDiagnosingBackups = true; - backupDiagnostics = null; - StateHasChanged(); - - try - { - ApiResponse? response = await Http.GetFromJsonAsync>( - $"api/postgres-clusters/{pgClusterId}/backups/diagnose?clusterId={ClusterId}"); - - if (response?.Success == true) - { - backupDiagnostics = response.Data; - } - } - catch (Exception ex) - { - pgMessage = $"Diagnostics failed: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isDiagnosingBackups = false; - StateHasChanged(); - } - } - - /// - /// Triggers an on-demand backup via the BFF proxy. - /// - private async Task TriggerPgBackup(Guid pgClusterId) - { - if (cluster is null) - { - return; - } - - isTriggeringPgBackup = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/postgres-clusters/{pgClusterId}/backup", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = "On-demand backup triggered successfully."; - pgMessageIsError = false; - await LoadPgClusterBackups(pgClusterId); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to trigger backup: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isTriggeringPgBackup = false; - StateHasChanged(); - } - } - - /// - /// Initiates a rolling restart of the CNPG cluster. - /// - private async Task RestartPgCluster(Guid pgClusterId) - { - if (cluster is null) - { - return; - } - - isRestartingPgCluster = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/postgres-clusters/{pgClusterId}/restart", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = "Rolling restart initiated."; - pgMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to restart: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isRestartingPgCluster = false; - StateHasChanged(); - } - } - - /// - /// Initiates a controlled switchover (failover) to a replica. - /// - private async Task FailoverPgCluster(Guid pgClusterId) - { - if (cluster is null) - { - return; - } - - isFailingOverPg = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/postgres-clusters/{pgClusterId}/failover", new - { - clusterId = ClusterId, - targetInstance = (string?)null - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = "Switchover initiated. The new primary will be elected shortly."; - pgMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to initiate switchover: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isFailingOverPg = false; - StateHasChanged(); - } - } - - /// - /// Drops a database from a CNPG cluster. - /// - private async Task DeletePgDatabase(Guid pgClusterId, string databaseName) - { - if (cluster is null) - { - return; - } - - isDeletingPgDb = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.DeleteAsync( - $"api/postgres-clusters/{pgClusterId}/databases/{databaseName}?clusterId={ClusterId}"); - - if (response.IsSuccessStatusCode) - { - pgMessage = $"Database '{databaseName}' dropped."; - pgMessageIsError = false; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to drop database: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isDeletingPgDb = false; - StateHasChanged(); - } - } - - /// - /// Shows the restore form pre-populated with a specific backup name. - /// If backupName is null, the form allows point-in-time recovery. - /// - private void ShowRestoreForm(Guid pgClusterId, string? backupName) - { - restoreSourcePgClusterId = pgClusterId; - restoreBackupName = backupName; - restoreClusterName = string.Empty; - restoreTargetTime = null; - restorePreview = null; - showRestoreForm = true; - StateHasChanged(); - } - - /// - /// Restores a PostgreSQL cluster from a backup or to a specific point in time. - /// Creates a new CNPG Cluster that bootstraps from the backup data stored in MinIO. - /// - private async Task RestoreFromBackup(Guid sourcePgClusterId) - { - if (cluster is null || string.IsNullOrWhiteSpace(restoreClusterName)) - { - pgMessage = "Please enter a name for the restored cluster."; - pgMessageIsError = true; - StateHasChanged(); - return; - } - - isRestoringBackup = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/postgres-clusters/{sourcePgClusterId}/restore", new - { - clusterId = ClusterId, - backupName = restoreBackupName, - restoredClusterName = restoreClusterName, - targetTime = restoreTargetTime.HasValue - ? new DateTimeOffset(restoreTargetTime.Value, TimeSpan.Zero) - : (DateTimeOffset?)null - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = $"Restore initiated. Cluster '{restoreClusterName}' is being created from backup."; - pgMessageIsError = false; - showRestoreForm = false; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Failed to restore: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isRestoringBackup = false; - StateHasChanged(); - } - } - - /// - /// Requests a dry-run preview of the restore operation. Shows the user exactly - /// what would be created (cluster name, storage path, recovery method) and any - /// warnings (e.g., target time before earliest PITR, no backups found). - /// - private async Task PreviewRestore(Guid sourcePgClusterId) - { - if (cluster is null) - { - return; - } - - isPreviewingRestore = true; - restorePreview = null; - StateHasChanged(); - - try - { - ApiResponse? response = await Http.PostAsJsonAsync( - $"api/postgres-clusters/{sourcePgClusterId}/restore/preview", new - { - clusterId = ClusterId, - backupName = restoreBackupName, - restoredClusterName = string.IsNullOrWhiteSpace(restoreClusterName) ? null : restoreClusterName, - targetTime = restoreTargetTime.HasValue - ? new DateTimeOffset(restoreTargetTime.Value, TimeSpan.Zero) - : (DateTimeOffset?)null - }).ContinueWith(t => t.Result.Content.ReadFromJsonAsync>()).Unwrap(); - - if (response?.Success == true) - { - restorePreview = response.Data; - } - else - { - pgMessage = $"Preview failed: {response?.Error ?? "Unknown error"}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Preview error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isPreviewingRestore = false; - StateHasChanged(); - } - } - - /// - /// Toggles the upgrade form for a PostgreSQL cluster. When opened, fetches - /// the list of available PostgreSQL versions from the cluster. - /// - private async Task ToggleUpgradeForm(Guid pgClusterId) - { - if (showUpgradeForm && upgradeTargetPgClusterId == pgClusterId) - { - showUpgradeForm = false; - return; - } - - upgradeTargetPgClusterId = pgClusterId; - selectedUpgradeVersion = string.Empty; - availableVersions = null; - showUpgradeForm = true; - StateHasChanged(); - - // Fetch available versions from the cluster. - - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/postgres-clusters/available-versions?clusterId={ClusterId}"); - - if (response?.Success == true) - { - availableVersions = response.Data; - } - else - { - availableVersions = new(); - pgMessage = "Could not load available versions."; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - availableVersions = new(); - pgMessage = $"Error loading versions: {ex.Message}"; - pgMessageIsError = true; - } - - StateHasChanged(); - } - - /// - /// Initiates a controlled PostgreSQL version upgrade. CNPG handles the rolling - /// update: it promotes a replica with the new version, then switches over the - /// primary. This is a zero-downtime operation. - /// - private async Task UpgradePgCluster(Guid pgClusterId) - { - if (cluster is null || string.IsNullOrEmpty(selectedUpgradeVersion)) - { - return; - } - - isUpgradingPgCluster = true; - pgMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/postgres-clusters/{pgClusterId}/upgrade", new - { - clusterId = ClusterId, - targetVersion = selectedUpgradeVersion - }); - - if (response.IsSuccessStatusCode) - { - pgMessage = $"Upgrade to PostgreSQL {selectedUpgradeVersion} initiated. Rolling update in progress."; - pgMessageIsError = false; - showUpgradeForm = false; - await LoadPgClusters(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - pgMessage = $"Upgrade failed: {error}"; - pgMessageIsError = true; - } - } - catch (Exception ex) - { - pgMessage = $"Error: {ex.Message}"; - pgMessageIsError = true; - } - finally - { - isUpgradingPgCluster = false; - StateHasChanged(); - } - } - - // ─── Redis methods ────────────────────────────────────────────────── - - /// - /// Loads all Redis instances for this Kubernetes cluster from the - /// Provisioning service via the BFF proxy. If none are found, triggers - /// discovery automatically. - /// - private async Task LoadRedisClusters() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/redis-clusters?clusterId={ClusterId}"); - - if (response?.Success == true) - { - redisClusters = response.Data ?? new(); - } - else - { - redisClusters = new(); - } - - // Auto-discover if no instances are known yet. - - if (redisClusters.Count == 0 && !isAdoptingRedisClusters) - { - await AdoptRedisClusters(); - } - } - catch - { - redisClusters = new(); - } - - StateHasChanged(); - } - - /// - /// Expands or collapses a Redis instance card to show details, health, and - /// configuration controls. When expanding, loads live health data. - /// - private async Task ToggleRedisExpand(Guid redisId) - { - if (expandedRedisClusterId == redisId) - { - expandedRedisClusterId = null; - redisClusterHealth = null; - } - else - { - expandedRedisClusterId = redisId; - - // Pre-populate configuration fields. - - RedisClusterDto? redis = redisClusters?.FirstOrDefault(c => c.Id == redisId); - - if (redis is not null) - { - redisConfigReplicas = redis.Replicas; - redisConfigStorageSize = redis.StorageSize; - redisConfigSentinel = redis.SentinelEnabled ? "true" : "false"; - } - - await LoadRedisHealth(redisId); - } - } - - /// - /// Discovers and adopts existing Redis instances running on the cluster. - /// - private async Task AdoptRedisClusters() - { - if (cluster is null) - { - return; - } - - isAdoptingRedisClusters = true; - redisMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/redis-clusters/adopt", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - await LoadRedisClustersQuiet(); - redisMessage = "Discovery complete."; - redisMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - redisMessage = $"Discovery failed: {error}"; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisMessage = $"Error: {ex.Message}"; - redisMessageIsError = true; - } - finally - { - isAdoptingRedisClusters = false; - StateHasChanged(); - } - } - - /// - /// Reloads the Redis cluster list without triggering auto-discovery. - /// - private async Task LoadRedisClustersQuiet() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/redis-clusters?clusterId={ClusterId}"); - - if (response?.Success == true) - { - redisClusters = response.Data ?? new(); - } - } - catch - { - // Best-effort reload. - } - } - - /// - /// Creates a new Redis instance via the Provisioning service. - /// - private async Task CreateRedisCluster() - { - if (cluster is null || string.IsNullOrWhiteSpace(newRedisName)) - { - return; - } - - isCreatingRedis = true; - redisMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/redis-clusters", new - { - clusterId = ClusterId, - name = newRedisName, - @namespace = newRedisNamespace, - redisVersion = newRedisVersion, - replicas = newRedisReplicas, - storageSize = newRedisStorageSize, - sentinelEnabled = newRedisSentinel == "true" - }); - - if (response.IsSuccessStatusCode) - { - redisMessage = $"Redis instance '{newRedisName}' creation initiated."; - redisMessageIsError = false; - showCreateRedisForm = false; - newRedisName = string.Empty; - await LoadRedisClustersQuiet(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - redisMessage = $"Failed to create instance: {error}"; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisMessage = $"Error: {ex.Message}"; - redisMessageIsError = true; - } - finally - { - isCreatingRedis = false; - StateHasChanged(); - } - } - - /// - /// Loads live health data for a Redis instance from the cluster. - /// - private async Task LoadRedisHealth(Guid redisId) - { - isLoadingRedisHealth = true; - redisClusterHealth = null; - StateHasChanged(); - - try - { - ApiResponse? response = await Http.GetFromJsonAsync>( - $"api/redis-clusters/{redisId}/health?clusterId={ClusterId}"); - - if (response?.Success == true) - { - redisClusterHealth = response.Data; - } - } - catch - { - // Health data is best-effort. - } - finally - { - isLoadingRedisHealth = false; - StateHasChanged(); - } - } - - /// - /// Applies configuration changes (replicas, storage, sentinel) to a Redis instance. - /// - private async Task ConfigureRedisCluster(Guid redisId) - { - if (cluster is null) - { - return; - } - - isConfiguringRedis = true; - redisMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PutAsJsonAsync($"api/redis-clusters/{redisId}/configure", new - { - clusterId = ClusterId, - replicas = redisConfigReplicas, - storageSize = redisConfigStorageSize, - sentinelEnabled = redisConfigSentinel == "true" - }); - - if (response.IsSuccessStatusCode) - { - redisMessage = "Configuration applied successfully."; - redisMessageIsError = false; - await LoadRedisClustersQuiet(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - redisMessage = $"Failed to apply configuration: {error}"; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisMessage = $"Error: {ex.Message}"; - redisMessageIsError = true; - } - finally - { - isConfiguringRedis = false; - StateHasChanged(); - } - } - - /// - /// Initiates a rolling restart of the Redis instance. - /// - private async Task RestartRedisCluster(Guid redisId) - { - if (cluster is null) - { - return; - } - - isRestartingRedis = true; - redisMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/redis-clusters/{redisId}/restart", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - redisMessage = "Rolling restart initiated."; - redisMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - redisMessage = $"Failed to restart: {error}"; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisMessage = $"Error: {ex.Message}"; - redisMessageIsError = true; - } - finally - { - isRestartingRedis = false; - StateHasChanged(); - } - } - - /// - /// Initiates a failover to a replica. - /// - private async Task FailoverRedisCluster(Guid redisId) - { - if (cluster is null) - { - return; - } - - isFailingOverRedis = true; - redisMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/redis-clusters/{redisId}/failover", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - redisMessage = "Failover initiated."; - redisMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - redisMessage = $"Failed to failover: {error}"; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisMessage = $"Error: {ex.Message}"; - redisMessageIsError = true; - } - finally - { - isFailingOverRedis = false; - StateHasChanged(); - } - } - - /// - /// Toggles the upgrade form for a Redis instance and fetches available versions. - /// - private async Task ToggleRedisUpgradeForm(Guid redisId) - { - if (showRedisUpgradeForm && redisUpgradeTargetId == redisId) - { - showRedisUpgradeForm = false; - return; - } - - redisUpgradeTargetId = redisId; - selectedRedisUpgradeVersion = string.Empty; - redisAvailableVersions = null; - showRedisUpgradeForm = true; - StateHasChanged(); - - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/redis-clusters/available-versions?clusterId={ClusterId}"); - - if (response?.Success == true) - { - redisAvailableVersions = response.Data; - } - else - { - redisAvailableVersions = new(); - redisMessage = "Could not load available versions."; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisAvailableVersions = new(); - redisMessage = $"Error loading versions: {ex.Message}"; - redisMessageIsError = true; - } - - StateHasChanged(); - } - - /// - /// Initiates a Redis version upgrade. - /// - private async Task UpgradeRedisCluster(Guid redisId) - { - if (cluster is null || string.IsNullOrEmpty(selectedRedisUpgradeVersion)) - { - return; - } - - isUpgradingRedis = true; - redisMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/redis-clusters/{redisId}/upgrade", new - { - clusterId = ClusterId, - targetVersion = selectedRedisUpgradeVersion - }); - - if (response.IsSuccessStatusCode) - { - redisMessage = $"Upgrade to Redis {selectedRedisUpgradeVersion} initiated."; - redisMessageIsError = false; - showRedisUpgradeForm = false; - await LoadRedisClustersQuiet(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - redisMessage = $"Failed to upgrade: {error}"; - redisMessageIsError = true; - } - } - catch (Exception ex) - { - redisMessage = $"Error: {ex.Message}"; - redisMessageIsError = true; - } - finally - { - isUpgradingRedis = false; - StateHasChanged(); - } - } - - /// - /// Returns the Bootstrap badge CSS class for a Redis instance status. - /// - private static string RedisStatusBadgeClass(string status) => status switch - { - "Running" => "bg-success", - "Provisioning" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Upgrading" => "bg-info", - "Decommissioned" => "bg-secondary", - _ => "bg-secondary" - }; - - /// - /// Formats seconds into a human-readable uptime string. - /// - private static string FormatUptime(long seconds) - { - if (seconds < 3600) - { - return $"{seconds / 60}m"; - } - - if (seconds < 86400) - { - return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; - } - - return $"{seconds / 86400}d {(seconds % 86400) / 3600}h"; - } - - // ─── MongoDB methods ────────────────────────────────────────────────── - - /// - /// Loads all MongoDB clusters for this Kubernetes cluster from the - /// Provisioning service via the BFF proxy. If none are found, triggers - /// discovery automatically. - /// - private async Task LoadMongoClusters() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/mongo-clusters?clusterId={ClusterId}"); - - if (response?.Success == true) - { - mongoClusters = response.Data ?? new(); - } - else - { - mongoClusters = new(); - } - - // Auto-discover if no clusters are known yet. - - if (mongoClusters.Count == 0 && !isAdoptingMongoClusters) - { - await AdoptMongoClusters(); - } - } - catch - { - mongoClusters = new(); - } - - StateHasChanged(); - } - - /// - /// Reloads the MongoDB cluster list without triggering auto-discovery. - /// - private async Task LoadMongoClustersQuiet() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/mongo-clusters?clusterId={ClusterId}"); - - if (response?.Success == true) - { - mongoClusters = response.Data ?? new(); - } - } - catch - { - // Best-effort reload. - } - } - - /// - /// Expands or collapses a MongoDB cluster card to show details, health, and - /// configuration controls. When expanding, loads live health data and - /// pre-populates configuration fields. - /// - private async Task ToggleMongoExpand(Guid mongoId) - { - if (expandedMongoClusterId == mongoId) - { - expandedMongoClusterId = null; - mongoClusterHealth = null; - } - else - { - expandedMongoClusterId = mongoId; - - // Pre-populate configuration fields from the current state. - - MongoClusterDto? mongo = mongoClusters?.FirstOrDefault(c => c.Id == mongoId); - - if (mongo is not null) - { - mongoConfigMembers = mongo.Members; - mongoConfigStorageSize = mongo.StorageSize; - } - - await LoadMongoHealth(mongoId); - } - } - - /// - /// Discovers and adopts existing MongoDB clusters running on the cluster. - /// - private async Task AdoptMongoClusters() - { - if (cluster is null) - { - return; - } - - isAdoptingMongoClusters = true; - mongoMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/mongo-clusters/adopt", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - await LoadMongoClustersQuiet(); - mongoMessage = "Discovery complete."; - mongoMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - mongoMessage = $"Discovery failed: {error}"; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoMessage = $"Error: {ex.Message}"; - mongoMessageIsError = true; - } - finally - { - isAdoptingMongoClusters = false; - StateHasChanged(); - } - } - - /// - /// Creates a new MongoDB cluster via the Provisioning service. - /// - private async Task CreateMongoCluster() - { - if (cluster is null || string.IsNullOrWhiteSpace(newMongoName)) - { - return; - } - - isCreatingMongo = true; - mongoMessage = null; - StateHasChanged(); - - try - { - // If a storage bucket was selected, resolve its endpoint for the backup target. - - string? minioEndpoint = null; - string? minioBucket = newMongoBackupBucket; - string? minioCredentialsSecret = null; - - string? s3Region = null; - - if (!string.IsNullOrEmpty(newMongoBucketId) && Guid.TryParse(newMongoBucketId, out Guid bucketId)) - { - StorageBucketSummaryDto? selectedBucket = storageBuckets?.FirstOrDefault(b => b.Id == bucketId); - minioEndpoint = selectedBucket?.Endpoint; - minioCredentialsSecret = "cnpg-backup-creds"; - s3Region = selectedBucket?.Region; - } - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/mongo-clusters", new - { - clusterId = ClusterId, - name = newMongoName, - @namespace = newMongoNamespace, - mongoVersion = newMongoVersion, - members = newMongoMembers, - storageSize = newMongoStorageSize, - minioEndpoint = minioEndpoint, - minioBucket = minioBucket, - minioCredentialsSecret = minioCredentialsSecret, - s3Region = s3Region - }); - - if (response.IsSuccessStatusCode) - { - mongoMessage = $"MongoDB cluster '{newMongoName}' creation initiated."; - mongoMessageIsError = false; - showCreateMongoForm = false; - newMongoName = string.Empty; - await LoadMongoClustersQuiet(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - mongoMessage = $"Failed to create cluster: {error}"; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoMessage = $"Error: {ex.Message}"; - mongoMessageIsError = true; - } - finally - { - isCreatingMongo = false; - StateHasChanged(); - } - } - - /// - /// Loads live health data for a MongoDB cluster from the cluster. - /// - private async Task LoadMongoHealth(Guid mongoId) - { - isLoadingMongoHealth = true; - mongoClusterHealth = null; - StateHasChanged(); - - try - { - ApiResponse? response = await Http.GetFromJsonAsync>( - $"api/mongo-clusters/{mongoId}/health?clusterId={ClusterId}"); - - if (response?.Success == true) - { - mongoClusterHealth = response.Data; - } - } - catch - { - // Health data is best-effort. - } - finally - { - isLoadingMongoHealth = false; - StateHasChanged(); - } - } - - /// - /// Applies configuration changes (members, storage) to a MongoDB cluster. - /// - private async Task ConfigureMongoCluster(Guid mongoId) - { - if (cluster is null) - { - return; - } - - isConfiguringMongo = true; - mongoMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PutAsJsonAsync($"api/mongo-clusters/{mongoId}/configure", new - { - clusterId = ClusterId, - members = mongoConfigMembers, - storageSize = mongoConfigStorageSize - }); - - if (response.IsSuccessStatusCode) - { - mongoMessage = "Configuration applied successfully."; - mongoMessageIsError = false; - await LoadMongoClustersQuiet(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - mongoMessage = $"Failed to apply configuration: {error}"; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoMessage = $"Error: {ex.Message}"; - mongoMessageIsError = true; - } - finally - { - isConfiguringMongo = false; - StateHasChanged(); - } - } - - /// - /// Initiates a rolling restart of the MongoDB cluster. - /// - private async Task RestartMongoCluster(Guid mongoId) - { - if (cluster is null) - { - return; - } - - isRestartingMongo = true; - mongoMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/mongo-clusters/{mongoId}/restart", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - mongoMessage = "Rolling restart initiated."; - mongoMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - mongoMessage = $"Failed to restart: {error}"; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoMessage = $"Error: {ex.Message}"; - mongoMessageIsError = true; - } - finally - { - isRestartingMongo = false; - StateHasChanged(); - } - } - - /// - /// Initiates a primary stepdown on the MongoDB cluster to trigger a failover. - /// - private async Task StepDownMongoCluster(Guid mongoId) - { - if (cluster is null) - { - return; - } - - isSteppingDownMongo = true; - mongoMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/mongo-clusters/{mongoId}/stepdown", new - { - clusterId = ClusterId - }); - - if (response.IsSuccessStatusCode) - { - mongoMessage = "Primary stepdown initiated."; - mongoMessageIsError = false; - } - else - { - string error = await response.Content.ReadAsStringAsync(); - mongoMessage = $"Failed to stepdown: {error}"; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoMessage = $"Error: {ex.Message}"; - mongoMessageIsError = true; - } - finally - { - isSteppingDownMongo = false; - StateHasChanged(); - } - } - - /// - /// Toggles the upgrade form for a MongoDB cluster and fetches available versions. - /// - private async Task ToggleMongoUpgradeForm(Guid mongoId) - { - if (showMongoUpgradeForm && mongoUpgradeTargetId == mongoId) - { - showMongoUpgradeForm = false; - return; - } - - mongoUpgradeTargetId = mongoId; - selectedMongoUpgradeVersion = string.Empty; - mongoAvailableVersions = null; - showMongoUpgradeForm = true; - StateHasChanged(); - - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>( - $"api/mongo-clusters/available-versions?clusterId={ClusterId}"); - - if (response?.Success == true) - { - mongoAvailableVersions = response.Data; - } - else - { - mongoAvailableVersions = new(); - mongoMessage = "Could not load available versions."; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoAvailableVersions = new(); - mongoMessage = $"Error loading versions: {ex.Message}"; - mongoMessageIsError = true; - } - - StateHasChanged(); - } - - /// - /// Initiates a MongoDB version upgrade. - /// - private async Task UpgradeMongoCluster(Guid mongoId) - { - if (cluster is null || string.IsNullOrEmpty(selectedMongoUpgradeVersion)) - { - return; - } - - isUpgradingMongo = true; - mongoMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/mongo-clusters/{mongoId}/upgrade", new - { - clusterId = ClusterId, - targetVersion = selectedMongoUpgradeVersion - }); - - if (response.IsSuccessStatusCode) - { - mongoMessage = $"Upgrade to MongoDB {selectedMongoUpgradeVersion} initiated."; - mongoMessageIsError = false; - showMongoUpgradeForm = false; - await LoadMongoClustersQuiet(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - mongoMessage = $"Failed to upgrade: {error}"; - mongoMessageIsError = true; - } - } - catch (Exception ex) - { - mongoMessage = $"Error: {ex.Message}"; - mongoMessageIsError = true; - } - finally - { - isUpgradingMongo = false; - StateHasChanged(); - } - } - - /// - /// Returns the Bootstrap badge CSS class for a MongoDB cluster status. - /// - private static string MongoStatusBadgeClass(string status) => status switch - { - "Running" => "bg-success", - "Pending" => "bg-warning text-dark", - "Failed" => "bg-danger", - "Upgrading" => "bg-info", - _ => "bg-secondary" - }; - - // ─── Delete database cluster ────────────────────────────────────────── - - /// - /// Opens the delete confirmation modal for a database cluster. The user - /// clicked "Delete Cluster" on a PG, Redis, or MongoDB card — we record - /// which cluster they want to delete and pop the confirmation dialog. - /// - private void RequestDeleteDbCluster(Guid id, string name, string type) - { - pendingDeleteDbClusterId = id; - pendingDeleteDbClusterName = name; - pendingDeleteDbClusterType = type; - } - - /// - /// Executes the actual delete after the user confirms in the modal. - /// Routes to the correct BFF proxy endpoint based on the cluster type - /// (PostgreSQL, Redis, or MongoDB) and refreshes the list afterwards. - /// - private async Task ConfirmDeleteDbCluster() - { - if (pendingDeleteDbClusterId is null || pendingDeleteDbClusterType is null) - { - return; - } - - isDeletingDbCluster = true; - StateHasChanged(); - - try - { - // Determine the API path based on the cluster type. - - string apiPath = pendingDeleteDbClusterType switch - { - "PostgreSQL" => $"api/postgres-clusters/{pendingDeleteDbClusterId}?clusterId={ClusterId}", - "Redis" => $"api/redis-clusters/{pendingDeleteDbClusterId}?clusterId={ClusterId}", - "MongoDB" => $"api/mongo-clusters/{pendingDeleteDbClusterId}?clusterId={ClusterId}", - _ => throw new InvalidOperationException($"Unknown cluster type: {pendingDeleteDbClusterType}") - }; - - HttpResponseMessage response = await Http.DeleteAsync(apiPath); - - if (response.IsSuccessStatusCode) - { - // Refresh the appropriate list so the deleted cluster disappears. - - if (pendingDeleteDbClusterType == "PostgreSQL") - { - pgMessage = $"PostgreSQL cluster '{pendingDeleteDbClusterName}' deleted."; - pgMessageIsError = false; - await LoadPgClusters(); - } - else if (pendingDeleteDbClusterType == "Redis") - { - redisMessage = $"Redis cluster '{pendingDeleteDbClusterName}' deleted."; - redisMessageIsError = false; - await LoadRedisClusters(); - } - else if (pendingDeleteDbClusterType == "MongoDB") - { - mongoMessage = $"MongoDB cluster '{pendingDeleteDbClusterName}' deleted."; - mongoMessageIsError = false; - await LoadMongoClusters(); - } - } - else - { - string error = await response.Content.ReadAsStringAsync(); - - if (pendingDeleteDbClusterType == "PostgreSQL") - { - pgMessage = $"Failed to delete cluster: {error}"; - pgMessageIsError = true; - } - else if (pendingDeleteDbClusterType == "Redis") - { - redisMessage = $"Failed to delete cluster: {error}"; - redisMessageIsError = true; - } - else if (pendingDeleteDbClusterType == "MongoDB") - { - mongoMessage = $"Failed to delete cluster: {error}"; - mongoMessageIsError = true; - } - } - } - catch (Exception ex) - { - errorMessage = $"Failed to delete cluster: {ex.Message}"; - } - finally - { - isDeletingDbCluster = false; - pendingDeleteDbClusterId = null; - pendingDeleteDbClusterName = null; - pendingDeleteDbClusterType = null; - StateHasChanged(); - } - } - - // ─── Certificate methods ────────────────────────────────────────────── - - /// - /// Loads all Certificate Authorities from the cluster via the BFF proxy. - /// - private async Task LoadCertificateAuthorities() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters/{ClusterId}/certificates/cas"); - - if (response?.Success == true) - { - certificateAuthorities = response.Data ?? new(); - } - else - { - certificateAuthorities = new(); - } - } - catch - { - certificateAuthorities = new(); - } - - StateHasChanged(); - } - - /// - /// Loads server/leaf TLS certificates from all namespaces on the cluster. - /// These are service certs, not CAs — things like wildcard certs, - /// ingress certs, and externally imported certs. - /// - private async Task LoadCertificates() - { - isLoadingCertificates = true; - StateHasChanged(); - - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters/{ClusterId}/certificates/list"); - - if (response?.Success == true) - { - certificates = response.Data ?? new(); - } - else - { - certificates = new(); - } - } - catch - { - certificates = new(); - } - finally - { - isLoadingCertificates = false; - StateHasChanged(); - } - } - - /// - /// Creates a new internal CA on the cluster. - /// - private async Task CreateInternalCA() - { - if (string.IsNullOrWhiteSpace(newInternalCAName)) - { - return; - } - - isCreatingCA = true; - caMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/clusters/{ClusterId}/certificates/cas/internal", new - { - name = newInternalCAName, - organization = newInternalCAOrganization, - durationDays = newInternalCADurationDays - }); - - if (response.IsSuccessStatusCode) - { - caMessage = $"Internal CA '{newInternalCAName}' created."; - caMessageIsError = false; - showCreateInternalCAForm = false; - newInternalCAName = string.Empty; - await LoadCertificateAuthorities(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - caMessage = $"Failed to create CA: {error}"; - caMessageIsError = true; - } - } - catch (Exception ex) - { - caMessage = $"Error: {ex.Message}"; - caMessageIsError = true; - } - finally - { - isCreatingCA = false; - StateHasChanged(); - } - } - - /// - /// Creates a new domain-scoped CA on the cluster. - /// - private async Task CreateDomainCA() - { - if (string.IsNullOrWhiteSpace(newDomainCAName) || string.IsNullOrWhiteSpace(newDomainCADomains)) - { - return; - } - - isCreatingCA = true; - caMessage = null; - StateHasChanged(); - - try - { - List domains = newDomainCADomains - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(d => d.Trim()) - .ToList(); - - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/clusters/{ClusterId}/certificates/cas/domain", new - { - name = newDomainCAName, - domains = domains, - durationDays = newDomainCADurationDays, - tlsCert = importExternalCA ? newDomainCATlsCert : (string?)null, - tlsKey = importExternalCA ? newDomainCATlsKey : (string?)null - }); - - if (response.IsSuccessStatusCode) - { - caMessage = $"Domain CA '{newDomainCAName}' created."; - caMessageIsError = false; - showCreateDomainCAForm = false; - newDomainCAName = string.Empty; - newDomainCADomains = string.Empty; - importExternalCA = false; - await LoadCertificateAuthorities(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - caMessage = $"Failed to create domain CA: {error}"; - caMessageIsError = true; - } - } - catch (Exception ex) - { - caMessage = $"Error: {ex.Message}"; - caMessageIsError = true; - } - finally - { - isCreatingCA = false; - StateHasChanged(); - } - } - - /// - /// Deletes a CA from the cluster. - /// - private async Task DeleteCA(string caName) - { - isDeletingOrRotatingCA = true; - caMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.DeleteAsync($"api/clusters/{ClusterId}/certificates/cas/{caName}"); - - if (response.IsSuccessStatusCode) - { - caMessage = $"CA '{caName}' deleted."; - caMessageIsError = false; - await LoadCertificateAuthorities(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - caMessage = $"Failed to delete CA: {error}"; - caMessageIsError = true; - } - } - catch (Exception ex) - { - caMessage = $"Error: {ex.Message}"; - caMessageIsError = true; - } - finally - { - isDeletingOrRotatingCA = false; - StateHasChanged(); - } - } - - /// - /// Rotates a CA — triggers cert-manager to regenerate the CA key pair. - /// - private async Task RotateCA(string caName) - { - isDeletingOrRotatingCA = true; - caMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/clusters/{ClusterId}/certificates/cas/{caName}/rotate", null); - - if (response.IsSuccessStatusCode) - { - caMessage = $"CA '{caName}' rotation initiated."; - caMessageIsError = false; - await LoadCertificateAuthorities(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - caMessage = $"Failed to rotate CA: {error}"; - caMessageIsError = true; - } - } - catch (Exception ex) - { - caMessage = $"Error: {ex.Message}"; - caMessageIsError = true; - } - finally - { - isDeletingOrRotatingCA = false; - StateHasChanged(); - } - } - - /// - /// Returns the Bootstrap badge CSS class for a CA status. - /// - private static string CaStatusBadgeClass(string status) => status switch - { - "Ready" => "bg-success", - "NotReady" => "bg-danger", - "Trust-Only" => "bg-info", - "Discovered" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - /// - /// Publishes a discovered CA secret to the default trust bundle so all - /// workloads on the cluster can trust it. - /// - private async Task PublishToBundle(string secretName) - { - isDeletingOrRotatingCA = true; - caMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/clusters/{ClusterId}/certificates/cas/{secretName}/publish", - new { }); - - if (response.IsSuccessStatusCode) - { - caMessage = $"Certificate '{secretName}' published to trust bundle."; - caMessageIsError = false; - await LoadCertificateAuthorities(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - caMessage = $"Failed to publish: {error}"; - caMessageIsError = true; - } - } - catch (Exception ex) - { - caMessage = $"Error: {ex.Message}"; - caMessageIsError = true; - } - finally - { - isDeletingOrRotatingCA = false; - StateHasChanged(); - } - } - - // Publishes a leaf/server TLS certificate to the platform trust bundle - // so workloads across the cluster can trust it. Uses the same trust-manager - // Bundle mechanism as CAs but routes through the certificates endpoint. - - private async Task PublishCertToBundle(string secretName, string namespaceName) - { - isDeletingOrRotatingCA = true; - caMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/clusters/{ClusterId}/certificates/publish", - new { SecretName = secretName, Namespace = namespaceName }); - - if (response.IsSuccessStatusCode) - { - caMessage = $"Certificate '{secretName}' published to trust bundle."; - caMessageIsError = false; - await LoadCertificates(); - } - else - { - string error = await response.Content.ReadAsStringAsync(); - caMessage = $"Failed to publish: {error}"; - caMessageIsError = true; - } - } - catch (Exception ex) - { - caMessage = $"Error: {ex.Message}"; - caMessageIsError = true; - } - finally - { - isDeletingOrRotatingCA = false; - StateHasChanged(); - } - } - - // ─── Keycloak methods ───────────────────────────────────────────────── - - private void ActivateTab(string tab) - { - activeTab = tab; - - if (tab == "keycloak" && keycloakRealms is null) - { - _ = LoadKeycloakRealms(); - } - - if (tab == "storage" && storageInfo is null) - { - _ = LoadStorageInfo(); - _ = LoadBuckets(); - } - - if (tab == "databases" && pgClusters is null) - { - _ = LoadPgClusters(); - _ = LoadRedisClusters(); - _ = LoadMongoClusters(); - - // Also load storage buckets so the create form's backup - // bucket dropdown has options. - - if (storageBuckets is null) - { - _ = LoadBuckets(); - } - } - - if (tab == "certificates" && certificateAuthorities is null) - { - _ = LoadCertificateAuthorities(); - } - - if (tab == "certificates" && certificates is null) - { - _ = LoadCertificates(); - } - - if (tab == "settings" && clusterSettings is null) - { - _ = LoadClusterSettings(); - } - - if (tab == "harbor" && harborDataProjects is null) - { - _ = LoadHarborData(); - } - - if (tab == "harbor" && giteaInfo is null) - { - _ = LoadGiteaRunners(); - } - } - - private async Task LoadKeycloakRealms() - { - try - { - keycloakRealms = await Http.GetFromJsonAsync>($"api/keycloak-realms/cluster/{ClusterId}") ?? new(); - } - catch - { - keycloakRealms = new(); - } - - StateHasChanged(); - } - - private void ShowCreateRealmForm() => showCreateRealmForm = true; - - private async Task CreateRealm() - { - if (string.IsNullOrWhiteSpace(newRealmName)) { return; } - - isCreatingRealm = true; - HttpResponseMessage response = await Http.PostAsJsonAsync("api/keycloak-realms", new { ClusterId, Name = newRealmName }); - - if (response.IsSuccessStatusCode) - { - newRealmName = string.Empty; - showCreateRealmForm = false; - await LoadKeycloakRealms(); - } - - isCreatingRealm = false; - } - - private void SelectRealm(KeycloakRealmDto realm) - { - selectedRealm = realm; - realmBrandingForm = new RealmBrandingForm - { - LogoUrl = realm.Branding.LogoUrl, - BackgroundImageUrl = realm.Branding.BackgroundImageUrl, - BackgroundColor = realm.Branding.BackgroundColor, - PrimaryColor = realm.Branding.PrimaryColor, - SecondaryColor = realm.Branding.SecondaryColor, - TextColor = realm.Branding.TextColor - }; - realmPagesForm = new RealmPagesForm - { - LoginEnabled = realm.Pages.LoginEnabled, - ProfileEnabled = realm.Pages.ProfileEnabled, - RegistrationEnabled = realm.Pages.RegistrationEnabled, - ForgotPasswordEnabled = realm.Pages.ForgotPasswordEnabled - }; - } - - private async Task SaveRealmBranding() - { - if (selectedRealm is null) { return; } - - await Http.PutAsJsonAsync($"api/keycloak-realms/{selectedRealm.Id}/branding", realmBrandingForm); - await LoadKeycloakRealms(); - - if (keycloakRealms is not null) - { - selectedRealm = keycloakRealms.FirstOrDefault(r => r.Id == selectedRealm.Id); - } - } - - private async Task SaveRealmPages() - { - if (selectedRealm is null) { return; } - - await Http.PutAsJsonAsync($"api/keycloak-realms/{selectedRealm.Id}/pages", realmPagesForm); - await LoadKeycloakRealms(); - - if (keycloakRealms is not null) - { - selectedRealm = keycloakRealms.FirstOrDefault(r => r.Id == selectedRealm.Id); - } - } - - private async Task AddIdentityProvider() - { - if (selectedRealm is null || string.IsNullOrWhiteSpace(newIdpForm.Alias)) { return; } - - await Http.PostAsJsonAsync($"api/keycloak-realms/{selectedRealm.Id}/identity-providers", newIdpForm); - showAddIdpForm = false; - newIdpForm = new(); - await LoadKeycloakRealms(); - - if (keycloakRealms is not null) - { - selectedRealm = keycloakRealms.FirstOrDefault(r => r.Id == selectedRealm.Id); - } - } - - private async Task RemoveIdentityProvider(string alias) - { - if (selectedRealm is null) { return; } - - await Http.DeleteAsync($"api/keycloak-realms/{selectedRealm.Id}/identity-providers/{alias}"); - await LoadKeycloakRealms(); - - if (keycloakRealms is not null) - { - selectedRealm = keycloakRealms.FirstOrDefault(r => r.Id == selectedRealm.Id); - } - } - - private async Task AddOrganization() - { - if (selectedRealm is null || string.IsNullOrWhiteSpace(newOrgName)) { return; } - - await Http.PostAsJsonAsync($"api/keycloak-realms/{selectedRealm.Id}/organizations", new { Name = newOrgName }); - newOrgName = string.Empty; - await LoadKeycloakRealms(); - - if (keycloakRealms is not null) - { - selectedRealm = keycloakRealms.FirstOrDefault(r => r.Id == selectedRealm.Id); - } - } - - private async Task RemoveOrganization(string name) - { - if (selectedRealm is null) { return; } - - await Http.DeleteAsync($"api/keycloak-realms/{selectedRealm.Id}/organizations/{name}"); - await LoadKeycloakRealms(); - - if (keycloakRealms is not null) - { - selectedRealm = keycloakRealms.FirstOrDefault(r => r.Id == selectedRealm.Id); - } - } - - private async Task DeleteRealm(Guid realmId) - { - await Http.DeleteAsync($"api/keycloak-realms/{realmId}"); - selectedRealm = null; - await LoadKeycloakRealms(); - } - - // ─── Settings tab methods ──────────────────────────────────────────── - - private async Task LoadClusterSettings() - { - isLoadingSettings = true; - StateHasChanged(); - - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/clusters/{ClusterId}/settings"); - - if (response?.Data is not null) - { - clusterSettings = response.Data; - editableRegistries = new List(clusterSettings.AllowedRegistries); - } - } - catch - { - clusterSettings = null; - } - - isLoadingSettings = false; - StateHasChanged(); - } - - /// - /// Loads Harbor projects from the cluster's Harbor instance for the - /// Harbor tab, showing both regular projects and proxy caches. - /// - private async Task LoadHarborData() - { - isLoadingHarborData = true; - StateHasChanged(); - - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/harbor/{ClusterId}/projects"); - - if (response is { Success: true, Data: not null }) - { - harborDataProjects = response.Data; - } - else - { - harborDataProjects = new(); - } - } - catch - { - harborDataProjects = new(); - } - - isLoadingHarborData = false; - StateHasChanged(); - } - - /// - /// Loads Gitea instance info and act runner pod statuses from the - /// Repositories tab, querying the BFF proxy endpoint. - /// - private async Task LoadGiteaRunners() - { - isLoadingGiteaRunners = true; - StateHasChanged(); - - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/gitea/{ClusterId}/info"); - - if (response is { Success: true, Data: not null }) - { - giteaInfo = response.Data; - } - else - { - giteaInfo = new GiteaInfoDto(null, 0, false, new()); - } - } - catch - { - giteaInfo = new GiteaInfoDto(null, 0, false, new()); - } - - isLoadingGiteaRunners = false; - StateHasChanged(); - } - - private void AddRegistry() - { - string trimmed = newRegistryInput.Trim(); - - if (!string.IsNullOrWhiteSpace(trimmed) && !editableRegistries.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) - { - editableRegistries.Add(trimmed); - newRegistryInput = string.Empty; - } - } - - private void RemoveRegistry(string registry) - { - editableRegistries.Remove(registry); - } - - private void HandleRegistryKeyDown(KeyboardEventArgs e) - { - if (e.Key == "Enter") - { - AddRegistry(); - } - } - - private async Task SaveClusterSettings() - { - isSavingSettings = true; - settingsMessage = null; - StateHasChanged(); - - try - { - HttpResponseMessage response = await Http.PutAsJsonAsync( - $"api/clusters/{ClusterId}/settings", - new { AllowedRegistries = editableRegistries }); - - if (response.IsSuccessStatusCode) - { - settingsMessage = "Settings saved successfully."; - settingsMessageIsError = false; - - // Refresh from the cluster to confirm the change. - - clusterSettings = new ClusterSettingsDto(new List(editableRegistries)); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - settingsMessage = $"Failed to save settings: {body}"; - settingsMessageIsError = true; - } - } - catch (Exception ex) - { - settingsMessage = $"Error saving settings: {ex.Message}"; - settingsMessageIsError = true; - } - - isSavingSettings = false; - StateHasChanged(); - } -} diff --git a/src/EntKube.Web.Client/Pages/ClusterHealth.razor b/src/EntKube.Web.Client/Pages/ClusterHealth.razor deleted file mode 100644 index 07f8937..0000000 --- a/src/EntKube.Web.Client/Pages/ClusterHealth.razor +++ /dev/null @@ -1,287 +0,0 @@ -@page "/clusters/{ClusterId:guid}/health" -@inject HttpClient Http -@inject NavigationManager Navigation -@rendermode InteractiveAuto - -Cluster Health - -@if (isLoading) -{ -
-
- Loading health data... -
-
-} -else if (errorMessage is not null) -{ - - Back to Clusters -} -else if (report is not null) -{ -
-

@clusterName

- - @report.OverallStatus - -
- -

Health check at @report.CheckedAt.ToLocalTime().ToString("g")

- -
- - Back to Clusters -
- - -
-
-
-
-
Nodes
-

@report.ReadyNodes / @report.TotalNodes

-

Ready

-
-
-
- -
-
-
-
CPU
-

@report.CpuUtilizationPercent%

-

Utilization

-
-
-
-
-
-
-
- -
-
-
-
Memory
-

@report.MemoryUtilizationPercent%

-

Utilization

-
-
-
-
-
-
-
- -
-
-
-
Disk Pressure
-

@report.NodesWithDiskPressure

-

Nodes affected

-
-
-
-
- - -
-
-
-
-
Pods Running
-

@report.RunningPods

-
-
-
- -
-
-
-
Pods Pending
-

@report.PendingPods

-
-
-
- -
-
-
-
Pods Failed
-

@report.FailedPods

-
-
-
-
- - -
-
-
-
-
API Server Error Rate
-

@report.ApiServerErrorRatePercent%

-

5xx responses (5m window)

-
-
-
- -
-
-
-
Container Restarts
-

@report.ContainerRestartsLastHour

-

Last hour

-
-
-
-
-} - -@code { - [Parameter] - public Guid ClusterId { get; set; } - - private ClusterHealthReportDto? report; - private string? clusterName; - private string? errorMessage; - private bool isLoading = true; - private bool isRefreshing; - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadClusterName(); - await LoadHealth(); - isLoading = false; - } - - private async Task LoadClusterName() - { - try - { - ApiResponse? response = await Http.GetFromJsonAsync>( - $"api/clusters/{ClusterId}"); - - if (response?.Data is not null) - { - clusterName = response.Data.Name; - } - else - { - clusterName = "Unknown Cluster"; - } - } - catch - { - clusterName = "Unknown Cluster"; - } - } - - private async Task LoadHealth() - { - try - { - HttpResponseMessage response = await Http.GetAsync($"api/clusters/{ClusterId}/health"); - - if (response.IsSuccessStatusCode) - { - ApiResponse? result = - await response.Content.ReadFromJsonAsync>(); - - if (result?.Data is not null) - { - report = result.Data; - } - else - { - errorMessage = "Health data was empty."; - } - } - else - { - ApiResponse? errorResult = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResult?.Error ?? $"Failed to load health data (HTTP {(int)response.StatusCode})."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load health data: {ex.Message}"; - } - } - - private async Task RefreshHealth() - { - isRefreshing = true; - errorMessage = null; - await LoadHealth(); - isRefreshing = false; - } - - private static string GetOverallStatusBadgeClass(string status) => status switch - { - "Healthy" => "bg-success", - "Degraded" => "bg-warning text-dark", - "Critical" => "bg-danger", - _ => "bg-secondary" - }; - - private static string GetCardBorderClass(bool isWarning) => - isWarning ? "border-warning" : ""; - - private static string GetProgressBarClass(double percent) => percent switch - { - > 90 => "bg-danger", - > 75 => "bg-warning", - _ => "bg-success" - }; - - // --- DTOs --- - - private class ApiResponse - { - public T? Data { get; set; } - public string? Error { get; set; } - public bool Success { get; set; } - } - - private class ClusterDetailDto - { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - } - - private class ClusterHealthReportDto - { - public int TotalNodes { get; set; } - public int ReadyNodes { get; set; } - public double CpuUtilizationPercent { get; set; } - public double MemoryUtilizationPercent { get; set; } - public int RunningPods { get; set; } - public int PendingPods { get; set; } - public int FailedPods { get; set; } - public int ContainerRestartsLastHour { get; set; } - public double ApiServerErrorRatePercent { get; set; } - public int NodesWithDiskPressure { get; set; } - public string OverallStatus { get; set; } = string.Empty; - public DateTimeOffset CheckedAt { get; set; } - } -} diff --git a/src/EntKube.Web.Client/Pages/Clusters.razor b/src/EntKube.Web.Client/Pages/Clusters.razor deleted file mode 100644 index cfb35da..0000000 --- a/src/EntKube.Web.Client/Pages/Clusters.razor +++ /dev/null @@ -1,698 +0,0 @@ -@page "/clusters" -@using YamlDotNet.Serialization -@using YamlDotNet.Serialization.NamingConventions -@inject HttpClient Http -@inject NavigationManager Navigation -@rendermode InteractiveAuto - -Clusters - -

Kubernetes Clusters

-

Manage your registered Kubernetes clusters. Register new clusters, view their status, and remove clusters you no longer need.

- -@if (errorMessage is not null) -{ - -} - -
- -
- -@if (showRegisterForm) -{ -
-
-
Register New Cluster
- -
- - - - @if (tenants is not null) - { - @foreach (TenantDto tenant in tenants) - { - - } - } - -
-
- - - - @foreach (EnvironmentDto env in environments) - { - - } - - @if (newCluster.TenantId != Guid.Empty && environments.Count == 0) - { - No environments found for this tenant. Create one first. - } -
-
- - -
-
- - -
- @if (kubeConfigParseError is not null) - { -
@kubeConfigParseError
- } - @if (availableContexts.Count > 1) - { -
- - -
- } - @if (!string.IsNullOrEmpty(newCluster.ApiServerUrl)) - { -
- - -
- } - - -
-
-
-} - -@if (clusters is null) -{ -

Loading clusters...

-} -else if (clusters.Count == 0) -{ -
- No clusters registered yet. Click "Register Cluster" to add your first one. -
-} -else -{ - - - - - - - - - - - - - - @foreach (ClusterSummaryDto cluster in clusters) - { - - - - - - - - - - } - -
NameAPI ServerStatusHealthPrometheusLast Health CheckActions
@cluster.Name@cluster.ApiServerUrl - - @cluster.Status - - - @if (prometheusMap.TryGetValue(cluster.Id, out List? endpoints2) && endpoints2.Count > 0) - { - - Dashboard - - } - else - { - - } - - @if (prometheusMap.TryGetValue(cluster.Id, out List? endpoints) && endpoints.Count > 0) - { - @endpoints.Count instance(s) -
    - @foreach (PrometheusEndpointDto ep in endpoints) - { -
  • @ep.ServiceName (@ep.Namespace)
  • - } -
- } - else if (scannedClusters.Contains(cluster.Id)) - { - None found - - } - else - { - Not scanned - } -
@(cluster.LastHealthCheckAt?.ToString("g") ?? "Never") - - -
-} - -@code { - private List? clusters; - private List? tenants; - private List environments = new(); - private Dictionary> prometheusMap = new(); - private HashSet scannedClusters = new(); - private ClusterFormModel newCluster = new(); - private List availableContexts = new(); - private string? kubeConfigParseError; - private bool showRegisterForm; - private bool isSubmitting; - private bool isDeleting; - private bool isScanning; - private bool isInstalling; - private Guid? installingClusterId; - private string? errorMessage; - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadClusters(); - await LoadTenants(); - await LoadPrometheusForAllClusters(); - } - - private async Task LoadClusters() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>("api/clusters"); - - if (response is not null && response.Success) - { - clusters = response.Data ?? new List(); - } - else - { - errorMessage = response?.Error ?? "Failed to load clusters."; - clusters = new List(); - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load clusters: {ex.Message}"; - clusters = new List(); - } - } - - private async Task LoadTenants() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>("api/tenants"); - - if (response is not null && response.Success) - { - tenants = response.Data ?? new List(); - } - else - { - tenants = new List(); - } - } - catch - { - tenants = new List(); - } - } - - /// - /// Loads the persisted Prometheus endpoints for all clusters on page load. - /// This means previously scanned results are shown immediately without - /// needing to re-scan the cluster every time the page is visited. - /// - private async Task LoadPrometheusForAllClusters() - { - if (clusters is null || clusters.Count == 0) - { - return; - } - - foreach (ClusterSummaryDto cluster in clusters) - { - try - { - HttpResponseMessage response = await Http.GetAsync($"api/clusters/{cluster.Id}/prometheus"); - - if (response.IsSuccessStatusCode) - { - ApiResponse>? result = - await response.Content.ReadFromJsonAsync>>(); - - if (result?.Data is not null && result.Data.Count > 0) - { - prometheusMap[cluster.Id] = result.Data; - scannedClusters.Add(cluster.Id); - } - } - } - catch - { - // If we can't load Prometheus data for a cluster, just skip it. - // The user can always re-scan manually. - } - } - } - - private async Task OnTenantSelectedAsync() - { - // When the user picks a different tenant, load that tenant's environments. - - environments = new(); - newCluster.EnvironmentIdString = string.Empty; - - if (newCluster.TenantId != Guid.Empty) - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/tenants/{newCluster.TenantId}/environments"); - - if (response is not null && response.Success && response.Data is not null) - { - environments = response.Data; - } - } - catch - { - // If we can't load environments, the dropdown will be empty. - } - } - } - - private void ShowRegisterForm() - { - showRegisterForm = true; - newCluster = new ClusterFormModel(); - environments = new(); - availableContexts = new(); - kubeConfigParseError = null; - } - - private void CancelRegister() - { - showRegisterForm = false; - } - - /// - /// Called whenever the kubeconfig textarea changes. Parses the YAML to extract - /// available contexts and their associated cluster server URLs. If only one - /// context exists, it's auto-selected and the API server URL is filled in. - /// If multiple contexts exist, a dropdown is shown for the user to pick. - /// - private void OnKubeConfigChanged() - { - availableContexts = new(); - kubeConfigParseError = null; - newCluster.ApiServerUrl = string.Empty; - newCluster.SelectedContext = string.Empty; - - if (string.IsNullOrWhiteSpace(newCluster.KubeConfig)) - { - return; - } - - try - { - IDeserializer deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - KubeConfigYaml? config = deserializer.Deserialize(newCluster.KubeConfig); - - if (config?.Contexts is null || config.Contexts.Count == 0) - { - kubeConfigParseError = "No contexts found in the kubeconfig."; - return; - } - - if (config.Clusters is null || config.Clusters.Count == 0) - { - kubeConfigParseError = "No clusters found in the kubeconfig."; - return; - } - - // Build a lookup from cluster name to server URL. - - Dictionary clusterServerMap = new(); - - foreach (KubeConfigClusterEntry entry in config.Clusters) - { - if (entry.Name is not null && entry.Cluster?.Server is not null) - { - clusterServerMap[entry.Name] = entry.Cluster.Server; - } - } - - // Map each context to its cluster's server URL. - - foreach (KubeConfigContextEntry ctxEntry in config.Contexts) - { - if (ctxEntry.Name is not null && ctxEntry.Context?.Cluster is not null) - { - string serverUrl = clusterServerMap.GetValueOrDefault(ctxEntry.Context.Cluster, "unknown"); - availableContexts.Add(new KubeContextInfo(ctxEntry.Name, serverUrl)); - } - } - - if (availableContexts.Count == 0) - { - kubeConfigParseError = "Could not resolve any context to a cluster server URL."; - return; - } - - // If there's only one context, auto-select it. - - if (availableContexts.Count == 1) - { - newCluster.SelectedContext = availableContexts[0].Name; - newCluster.ApiServerUrl = availableContexts[0].ServerUrl; - } - } - catch (Exception ex) - { - kubeConfigParseError = $"Failed to parse kubeconfig: {ex.Message}"; - } - } - - private async Task RegisterCluster() - { - // Resolve the API server URL from the selected context if multiple were available. - - if (availableContexts.Count > 1 && !string.IsNullOrWhiteSpace(newCluster.SelectedContext)) - { - KubeContextInfo? selected = availableContexts.FirstOrDefault(c => c.Name == newCluster.SelectedContext); - - if (selected is not null) - { - newCluster.ApiServerUrl = selected.ServerUrl; - } - } - - if (string.IsNullOrWhiteSpace(newCluster.Name)) - { - errorMessage = "Cluster name is required."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.KubeConfig)) - { - errorMessage = "KubeConfig is required."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.ApiServerUrl)) - { - errorMessage = "Please paste a valid kubeconfig and select a context."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.SelectedContext)) - { - errorMessage = "Please select a context from the kubeconfig."; - return; - } - - if (newCluster.TenantId == Guid.Empty) - { - errorMessage = "A tenant must be selected."; - return; - } - - if (newCluster.EnvironmentId == Guid.Empty) - { - errorMessage = "An environment must be selected."; - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - RegisterClusterRequest request = new( - newCluster.Name, - newCluster.ApiServerUrl, - newCluster.KubeConfig, - newCluster.TenantId, - newCluster.SelectedContext, - newCluster.EnvironmentId); - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/clusters", request); - - if (response.IsSuccessStatusCode) - { - showRegisterForm = false; - await LoadClusters(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to register cluster."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to register cluster: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task DeleteCluster(Guid id) - { - isDeleting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync($"api/clusters/{id}"); - - if (response.IsSuccessStatusCode) - { - await LoadClusters(); - } - else - { - errorMessage = "Failed to delete cluster."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to delete cluster: {ex.Message}"; - } - finally - { - isDeleting = false; - } - } - - private async Task DiscoverPrometheus(Guid clusterId) - { - isScanning = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/clusters/{clusterId}/discover-prometheus", null); - - if (response.IsSuccessStatusCode) - { - ApiResponse>? result = - await response.Content.ReadFromJsonAsync>>(); - - if (result?.Data is not null) - { - prometheusMap[clusterId] = result.Data; - } - } - else - { - errorMessage = "Failed to scan for Prometheus."; - } - - // Track that this cluster has been scanned so the UI knows - // whether to show "Not scanned" or "None found + Install" button. - - scannedClusters.Add(clusterId); - } - catch (Exception ex) - { - errorMessage = $"Failed to scan for Prometheus: {ex.Message}"; - } - finally - { - isScanning = false; - } - } - - private async Task InstallPrometheus(Guid clusterId) - { - isInstalling = true; - installingClusterId = clusterId; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/clusters/{clusterId}/install-prometheus", - new { Namespace = "monitoring" }); - - if (response.IsSuccessStatusCode) - { - // Re-scan to pick up the newly installed Prometheus. - - await DiscoverPrometheus(clusterId); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to install Prometheus."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to install Prometheus: {ex.Message}"; - } - finally - { - isInstalling = false; - installingClusterId = null; - } - } - - private static string GetStatusBadgeClass(string status) => status switch - { - "Connected" => "bg-success", - "Pending" => "bg-warning text-dark", - "Unreachable" => "bg-danger", - _ => "bg-secondary" - }; - - // DTOs matching the API response shapes - - private record ApiResponse - { - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - } - - private record ClusterSummaryDto( - Guid Id, - string Name, - string ApiServerUrl, - string Status, - string? Provider, - Guid TenantId, - DateTimeOffset? LastHealthCheckAt); - - private record TenantDto(Guid Id, string Name, string Slug); - - private record RegisterClusterRequest(string Name, string ApiServerUrl, string KubeConfig, Guid TenantId, string ContextName, Guid EnvironmentId); - - private record PrometheusEndpointDto(string Url, string Namespace, string ServiceName); - - private record KubeContextInfo(string Name, string ServerUrl); - - // KubeConfig YAML deserialization models - - private class KubeConfigYaml - { - public List? Clusters { get; set; } - public List? Contexts { get; set; } - } - - private class KubeConfigClusterEntry - { - public string? Name { get; set; } - public KubeConfigClusterData? Cluster { get; set; } - } - - private class KubeConfigClusterData - { - public string? Server { get; set; } - } - - private class KubeConfigContextEntry - { - public string? Name { get; set; } - public KubeConfigContextData? Context { get; set; } - } - - private class KubeConfigContextData - { - public string? Cluster { get; set; } - } - - private record EnvironmentDto(Guid Id, Guid TenantId, string Name, string Slug, string Status, DateTimeOffset CreatedAt); - - private class ClusterFormModel - { - public string TenantIdString { get; set; } = string.Empty; - public string EnvironmentIdString { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string ApiServerUrl { get; set; } = string.Empty; - public string KubeConfig { get; set; } = string.Empty; - public string SelectedContext { get; set; } = string.Empty; - - public Guid TenantId => Guid.TryParse(TenantIdString, out Guid id) ? id : Guid.Empty; - public Guid EnvironmentId => Guid.TryParse(EnvironmentIdString, out Guid id) ? id : Guid.Empty; - } -} diff --git a/src/EntKube.Web.Client/Pages/Counter.razor b/src/EntKube.Web.Client/Pages/Counter.razor index 26e24e6..1c21c80 100644 --- a/src/EntKube.Web.Client/Pages/Counter.razor +++ b/src/EntKube.Web.Client/Pages/Counter.razor @@ -1,4 +1,5 @@ @page "/counter" +@rendermode InteractiveAuto Counter diff --git a/src/EntKube.Web.Client/Pages/CustomerApps.razor b/src/EntKube.Web.Client/Pages/CustomerApps.razor deleted file mode 100644 index f816e4c..0000000 --- a/src/EntKube.Web.Client/Pages/CustomerApps.razor +++ /dev/null @@ -1,232 +0,0 @@ -@page "/customers/{CustomerId:guid}/apps" -@inject HttpClient Http -@inject NavigationManager Navigation -@rendermode InteractiveAuto - -Apps — @(customerName ?? "Loading...") - - - -@if (errorMessage is not null) -{ - -} - -
-

Apps

- -
- -@if (apps is null) -{ -

Loading apps...

-} -else if (apps.Count == 0) -{ -
- -

No apps created yet. Create your first app to start deploying workloads.

-
-} -else -{ -
- @foreach (AppSummaryDto app in apps) - { -
-
-
-
-
@app.Name
- @app.Type -
-

@app.Slug

-
- @app.Status - - @app.EnvironmentCount env(s) - -
- Created @app.CreatedAt.ToString("yyyy-MM-dd") -
- -
-
- } -
-} - -@if (showCreateForm) -{ -
-
-
Create New App
- -
-
- - -
-
- - -
-
- - - - - -
-
-
- - -
-
-
-
-} - -@code { - [Parameter] public Guid CustomerId { get; set; } - [SupplyParameterFromQuery] public Guid tenantId { get; set; } - - private List? apps; - private string? customerName; - private string? errorMessage; - private bool showCreateForm; - private bool isSubmitting; - private AppFormModel newApp = new(); - - protected override async Task OnInitializedAsync() - { - // Skip API calls during SSR prerender — only load data in interactive mode. - - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadApps(); - } - - private async Task LoadApps() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/apps/by-customer/{CustomerId}"); - - if (response is { Success: true, Data: not null }) - { - apps = response.Data; - - // Derive customer name from the first app if available. - - if (apps.Count > 0) - { - customerName = "Apps"; - } - else - { - customerName = "Apps"; - } - } - else - { - apps = new(); - errorMessage = response?.Error ?? "Failed to load apps."; - } - } - catch (Exception ex) - { - apps = new(); - errorMessage = $"Failed to load apps: {ex.Message}"; - } - } - - private async Task CreateApp() - { - isSubmitting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/apps/by-customer/{CustomerId}", - new { TenantId = tenantId, Name = newApp.Name, Slug = newApp.Slug, Type = newApp.Type }); - - if (response.IsSuccessStatusCode) - { - showCreateForm = false; - newApp = new(); - await LoadApps(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to create app: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to create app: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private static string GetTypeBadge(string type) => type switch - { - "Deployment" => "bg-info text-dark", - "HelmChart" => "bg-purple text-white", - _ => "bg-secondary" - }; - - private static string GetStatusBadge(string status) => status switch - { - "Active" => "bg-success", - "Suspended" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - // ─── DTOs ──────────────────────────────────────────────────────────── - - private record ApiResponse(bool Success, T? Data, string? Error, DateTimeOffset Timestamp); - private record AppSummaryDto(Guid Id, Guid CustomerId, Guid TenantId, string Name, string Slug, - string Type, string Status, int EnvironmentCount, DateTimeOffset CreatedAt); - - private class AppFormModel - { - public string Name { get; set; } = ""; - public string Slug { get; set; } = ""; - public string Type { get; set; } = "Deployment"; - } -} diff --git a/src/EntKube.Web.Client/Pages/Home.razor b/src/EntKube.Web.Client/Pages/Home.razor deleted file mode 100644 index b7ba9b2..0000000 --- a/src/EntKube.Web.Client/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. diff --git a/src/EntKube.Web.Client/Pages/MinioTenants.razor b/src/EntKube.Web.Client/Pages/MinioTenants.razor deleted file mode 100644 index 16c0a93..0000000 --- a/src/EntKube.Web.Client/Pages/MinioTenants.razor +++ /dev/null @@ -1,575 +0,0 @@ -@page "/minio-tenants" -@inject HttpClient Http -@rendermode InteractiveAuto - -MinIO Tenants - -

MinIO Tenants

-

Manage MinIO storage tenants across your clusters. Each tenant is an independent storage cluster with configurable pools (servers, disks, storage class).

- -@if (errorMessage is not null) -{ - -} - -@if (successMessage is not null) -{ - -} - -
- -
- -@* ─── Create Tenant Form ──────────────────────────────────────────────── *@ - -@if (showCreateForm) -{ -
-
-
Create New MinIO Tenant
- -
-
- - -
-
- - -
-
- - @if (clusters is null) - { -

Loading clusters...

- } - else - { - - } -
-
- -
Storage Pools
-

Each pool defines a set of identical servers. MinIO distributes data across all volumes using erasure coding.

- - @for (int i = 0; i < newTenant.Pools.Count; i++) - { - int index = i; -
-
-
- Pool @(index + 1) - @if (newTenant.Pools.Count > 1) - { - - } -
-
-
- - -
Number of pods
-
-
- - -
Disks per pod
-
-
- - -
Per volume
-
-
- - -
K8s storage class
-
-
-
-
- } - - - - @if (newTenant.Pools.Count > 0) - { -
- Estimated capacity: @CalculateCapacity(newTenant.Pools) -
- } - -
- - -
-
-
-} - -@* ─── Tenant List ─────────────────────────────────────────────────────── *@ - -@if (tenants is null) -{ -

Loading tenants...

-} -else if (tenants.Count == 0) -{ -
- No MinIO tenants provisioned yet. Click "Create Tenant" to deploy your first storage cluster. -
-} -else -{ -
- @foreach (MinioTenantSummaryDto tenant in tenants) - { -
-
-
- @tenant.Name - @tenant.Status -
-
-
-
Namespace
-
@tenant.Namespace
-
Endpoint
-
@tenant.Endpoint
-
Capacity
-
@tenant.TotalCapacity
-
Pools
-
@tenant.PoolCount pools, @tenant.TotalServers servers
-
Created
-
@tenant.CreatedAt.ToString("g")
-
-
- -
-
- } -
-} - -@* ─── Configure Tenant Modal ──────────────────────────────────────────── *@ - -@if (configuringTenant is not null) -{ -
-
-
Configure: @configuringTenant.Name
- -
-
-
Pool Configuration
- - @for (int i = 0; i < configurePools.Count; i++) - { - int index = i; -
-
-
- Pool @(index + 1) - @if (configurePools.Count > 1) - { - - } -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- } - - - -
- New estimated capacity: @CalculateCapacity(configurePools) -
- - -
-
-} - -@code { - private List? tenants; - private List? clusters; - private CreateTenantFormModel newTenant = new(); - private MinioTenantSummaryDto? configuringTenant; - private List configurePools = new(); - private bool showCreateForm; - private bool isSubmitting; - private bool isConfiguring; - private string? errorMessage; - private string? successMessage; - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadTenants(); - } - - private async Task LoadTenants() - { - try - { - tenants = await Http.GetFromJsonAsync>("api/minio-tenants"); - tenants ??= new List(); - } - catch (Exception ex) - { - errorMessage = $"Failed to load tenants: {ex.Message}"; - tenants = new List(); - } - } - - private async Task LoadClusters() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>("api/clusters"); - - clusters = response?.Data ?? new List(); - } - catch - { - clusters = new List(); - } - } - - private async Task ShowCreateForm() - { - showCreateForm = true; - newTenant = new CreateTenantFormModel(); - newTenant.Pools.Add(new PoolFormModel { Servers = 4, VolumesPerServer = 4, StorageSize = "100Gi", StorageClass = "ceph-block" }); - await LoadClusters(); - } - - private void CancelCreate() - { - showCreateForm = false; - } - - private void AddPool() - { - newTenant.Pools.Add(new PoolFormModel { Servers = 4, VolumesPerServer = 4, StorageSize = "100Gi", StorageClass = "ceph-block" }); - } - - private void RemovePool(int index) - { - newTenant.Pools.RemoveAt(index); - } - - private async Task CreateTenant() - { - if (string.IsNullOrWhiteSpace(newTenant.Name) || - string.IsNullOrWhiteSpace(newTenant.Namespace) || - string.IsNullOrWhiteSpace(newTenant.ClusterId)) - { - errorMessage = "Tenant name, namespace, and cluster are required."; - return; - } - - if (newTenant.Pools.Count == 0) - { - errorMessage = "At least one pool is required."; - return; - } - - isSubmitting = true; - errorMessage = null; - successMessage = null; - - try - { - object request = new - { - ClusterId = Guid.Parse(newTenant.ClusterId), - newTenant.Name, - newTenant.Namespace, - Pools = newTenant.Pools.Select(p => new - { - p.Servers, - p.VolumesPerServer, - p.StorageSize, - p.StorageClass - }).ToList() - }; - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/minio-tenants", request); - - if (response.IsSuccessStatusCode) - { - showCreateForm = false; - successMessage = $"Tenant '{newTenant.Name}' created successfully."; - await LoadTenants(); - } - else - { - ErrorResponse? error = await response.Content.ReadFromJsonAsync(); - errorMessage = error?.Error ?? "Failed to create tenant."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to create tenant: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private void ShowConfigureForm(MinioTenantSummaryDto tenant) - { - configuringTenant = tenant; - // Initialize with current pool count and typical values - // (full detail fetch would give exact values, but summary has count/servers) - configurePools = new List - { - new() { Servers = tenant.TotalServers, VolumesPerServer = 4, StorageSize = "100Gi", StorageClass = "ceph-block" } - }; - } - - private async Task SaveConfiguration() - { - if (configuringTenant is null || configurePools.Count == 0) - { - return; - } - - isConfiguring = true; - errorMessage = null; - successMessage = null; - - try - { - object request = new - { - ClusterId = configuringTenant.ClusterId, - Pools = configurePools.Select(p => new - { - p.Servers, - p.VolumesPerServer, - p.StorageSize, - p.StorageClass - }).ToList() - }; - - HttpResponseMessage response = await Http.PutAsJsonAsync( - $"api/minio-tenants/{configuringTenant.Id}/configure", request); - - if (response.IsSuccessStatusCode) - { - configuringTenant = null; - successMessage = "Tenant configuration updated."; - await LoadTenants(); - } - else - { - ErrorResponse? error = await response.Content.ReadFromJsonAsync(); - errorMessage = error?.Error ?? "Failed to update configuration."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to update configuration: {ex.Message}"; - } - finally - { - isConfiguring = false; - } - } - - private async Task DeleteTenant(MinioTenantSummaryDto tenant) - { - errorMessage = null; - successMessage = null; - - try - { - HttpRequestMessage request = new(HttpMethod.Delete, $"api/minio-tenants/{tenant.Id}") - { - Content = JsonContent.Create(new { ClusterId = tenant.ClusterId }) - }; - - HttpResponseMessage response = await Http.SendAsync(request); - - if (response.IsSuccessStatusCode) - { - successMessage = $"Tenant '{tenant.Name}' deleted."; - await LoadTenants(); - } - else - { - ErrorResponse? error = await response.Content.ReadFromJsonAsync(); - errorMessage = error?.Error ?? "Failed to delete tenant."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to delete tenant: {ex.Message}"; - } - } - - private static string CalculateCapacity(List pools) - { - long totalGi = 0; - - foreach (PoolFormModel pool in pools) - { - if (long.TryParse(pool.StorageSize.Replace("Gi", "").Replace("Ti", ""), out long size)) - { - long multiplier = pool.StorageSize.Contains("Ti") ? 1024 : 1; - totalGi += pool.Servers * pool.VolumesPerServer * size * multiplier; - } - } - - if (totalGi >= 1024) - { - return $"{totalGi / 1024.0:F1} TiB ({totalGi} GiB)"; - } - - return $"{totalGi} GiB"; - } - - private static string GetStatusBadgeClass(string status) => status switch - { - "Running" => "bg-success", - "Provisioning" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Decommissioned" => "bg-secondary", - _ => "bg-secondary" - }; - - private static string GetTenantCardBorder(string status) => status switch - { - "Degraded" => "border-danger", - "Provisioning" => "border-warning", - _ => "" - }; - - // ─── DTOs ──────────────────────────────────────────────────────────────── - - private record MinioTenantSummaryDto( - Guid Id, - Guid ClusterId, - string Name, - string Namespace, - string Endpoint, - string ConsoleEndpoint, - string Status, - string TotalCapacity, - int PoolCount, - int TotalServers, - DateTimeOffset CreatedAt); - - private record ClusterSummaryDto( - Guid Id, - string Name, - string ApiServerUrl, - string Status, - DateTimeOffset? LastHealthCheckAt); - - private record ApiResponse - { - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - } - - private record ErrorResponse - { - public string? Error { get; init; } - } - - // ─── Form Models ───────────────────────────────────────────────────────── - - private class CreateTenantFormModel - { - public string Name { get; set; } = string.Empty; - public string Namespace { get; set; } = string.Empty; - public string ClusterId { get; set; } = string.Empty; - public List Pools { get; set; } = new(); - } - - private class PoolFormModel - { - public int Servers { get; set; } = 4; - public int VolumesPerServer { get; set; } = 4; - public string StorageSize { get; set; } = "100Gi"; - public string StorageClass { get; set; } = "ceph-block"; - } -} diff --git a/src/EntKube.Web.Client/Pages/Secrets.razor b/src/EntKube.Web.Client/Pages/Secrets.razor deleted file mode 100644 index b71dc85..0000000 --- a/src/EntKube.Web.Client/Pages/Secrets.razor +++ /dev/null @@ -1,958 +0,0 @@ -@page "/secrets" -@inject HttpClient Http -@rendermode InteractiveAuto - -Secrets Manager - -

Secrets Manager

-

Manage encrypted secrets for infrastructure services and applications. Secrets are encrypted at rest using envelope encryption with AES-256-GCM.

- -@if (errorMessage is not null) -{ - -} - -@if (successMessage is not null) -{ - -} - -@* ─── Vault Status Card ──────────────────────────────────────────────── *@ - -@if (vaultStatus is null) -{ -
Loading vault status...
-} -else -{ -
-
-
-
-
Vault Status
- @if (!vaultStatus.IsInitialized) - { -

Not Initialized

-

The vault has not been initialized yet.

- } - else if (vaultStatus.IsSealed) - { -

Sealed

-

The vault is sealed. Secrets cannot be accessed.

- } - else - { -

Unsealed

-

The vault is ready to serve secrets.

- } -
-
-
- -
-
-
-
Secrets
-

@(secrets?.Count ?? 0)

-

Stored paths

-
-
-
- -
-
-
-
Service Tokens
-

@(tokens?.Count ?? 0)

-

Active tokens

-
-
-
-
- - @* ─── Initialize Vault ────────────────────────────────────────────── *@ - - @if (!vaultStatus.IsInitialized) - { -
-
-
Initialize Vault
-

Generate the master encryption key and create Shamir recovery shares. This can only be done once.

- -
-
- - -
Number of Shamir shares to generate
-
-
- - -
Shares needed to reconstruct the master key
-
-
- - -
-
- } - - @* ─── Recovery Shares (shown once after initialization) ───────────── *@ - - @if (recoveryShares is not null) - { -
-
-
Recovery Shares
-

These recovery shares are shown ONCE. Store them securely in separate locations. They are needed only for disaster recovery when the KEK is lost.

- -
- - - - - - - - - @for (int i = 0; i < recoveryShares.Count; i++) - { - - - - - } - -
Share #Value
@(i + 1)@recoveryShares[i]
-
- - -
-
- } - - @* ─── Seal/Unseal Controls ────────────────────────────────────────── *@ - - @if (vaultStatus.IsInitialized) - { -
-
-
Vault Controls
-
- @if (!vaultStatus.IsSealed) - { - - } - else - { -
-
- - -
- -
- } - - -
-
-
- } - - @* ─── Tabs ────────────────────────────────────────────────────────── *@ - - @if (vaultStatus.IsInitialized && !vaultStatus.IsSealed) - { - - - @* ═══ Secrets Tab ════════════════════════════════════════════════ *@ - - @if (activeTab == "secrets") - { -
-

Stored Secrets

- -
- - @* ─── Create Secret Form ─── *@ - - @if (showCreateSecretForm) - { -
-
-
Create / Update Secret
-

Store a secret at a path. If the path already exists, a new version is created.

- -
-
- - -
Use forward slashes to organize secrets hierarchically.
-
-
- -
- - -
- -
- - -
-
-
- } - - @* ─── Secrets List ─── *@ - - @if (secrets is null) - { -
Loading...
- } - else if (secrets.Count == 0) - { -
- No secrets stored yet. Click "Create Secret" to store your first encrypted secret. -
- } - else - { -
- @foreach (string path in secrets) - { -
-
- - @path -
-
- - -
-
- - @if (viewingSecretPath == path && viewedSecret is not null) - { -
-
- Secret Value (v@(viewedSecret.Version)) - -
-
@viewedSecret.Data
-
- } - } -
- } - } - - @* ═══ Service Tokens Tab ═════════════════════════════════════════ *@ - - @if (activeTab == "tokens") - { -
-

Service Tokens

- -
- - @* ─── Create Token Form ─── *@ - - @if (showCreateTokenForm) - { -
-
-
Create Service Token
-

Create a scoped access token for a service. The plaintext token is shown once.

- -
-
- - -
-
- -
- -

Define path-based access policies. Each policy grants specific operations on secrets matching a path prefix.

- - @for (int i = 0; i < newTokenPolicies.Count; i++) - { - int index = i; -
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- @if (newTokenPolicies.Count > 1) - { - - } -
-
- } - - -
- -
- - -
-
-
- } - - @* ─── Generated Token (shown once) ─── *@ - - @if (generatedToken is not null) - { -
- New Token Created: Copy this token now — it will not be shown again. -
- @generatedToken -
- -
- } - - @* ─── Token List ─── *@ - - @if (tokens is null) - { -
Loading...
- } - else if (tokens.Count == 0) - { -
- No service tokens created yet. Create a token to allow services to access secrets programmatically. -
- } - else - { - - - - - - - - - - - @foreach (TokenInfoDto token in tokens) - { - - - - - - - } - -
NameCreatedPoliciesActions
@token.Name@token.CreatedAt.ToString("g") - @foreach (string policy in token.Policies) - { - @policy - } - - -
- } - } - } -} - -@code { - // ─── Vault status ──────────────────────────────────────────────────── - - private VaultStatusDto? vaultStatus; - private string? errorMessage; - private string? successMessage; - private string activeTab = "secrets"; - - // ─── Initialize state ──────────────────────────────────────────────── - - private int initTotalShares = 5; - private int initThreshold = 3; - private bool isInitializing; - private List? recoveryShares; - - // ─── Seal/Unseal state ─────────────────────────────────────────────── - - private bool isSealing; - private bool isUnsealing; - private string unsealShare = string.Empty; - - // ─── Secrets state ─────────────────────────────────────────────────── - - private List? secrets; - private bool showCreateSecretForm; - private string newSecretPath = string.Empty; - private string newSecretData = string.Empty; - private bool isPuttingSecret; - private bool isLoadingSecret; - private bool isDeletingSecret; - private string? viewingSecretPath; - private SecretValueDto? viewedSecret; - - // ─── Tokens state ──────────────────────────────────────────────────── - - private List? tokens; - private bool showCreateTokenForm; - private string newTokenName = string.Empty; - private List newTokenPolicies = new() { new() }; - private bool isCreatingToken; - private bool isRevokingToken; - private string? generatedToken; - - // ─── Lifecycle ─────────────────────────────────────────────────────── - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await RefreshStatus(); - } - - private async Task RefreshStatus() - { - try - { - VaultStatusDto? status = await Http.GetFromJsonAsync("api/vault/status"); - vaultStatus = status; - - if (vaultStatus is not null && vaultStatus.IsInitialized && !vaultStatus.IsSealed) - { - await LoadSecrets(); - await LoadTokens(); - } - } - catch (Exception ex) - { - errorMessage = $"Failed to connect to vault: {ex.Message}"; - } - } - - // ─── Initialize ────────────────────────────────────────────────────── - - private async Task InitializeVault() - { - isInitializing = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/vault/init", new - { - TotalShares = initTotalShares, - Threshold = initThreshold - }); - - if (response.IsSuccessStatusCode) - { - InitializeResponseDto? result = await response.Content.ReadFromJsonAsync(); - recoveryShares = result?.Shares; - successMessage = "Vault initialized successfully."; - await RefreshStatus(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to initialize vault: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isInitializing = false; - } - } - - private void DismissRecoveryShares() - { - recoveryShares = null; - } - - // ─── Seal ──────────────────────────────────────────────────────────── - - private async Task SealVault() - { - isSealing = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync("api/vault/seal", null); - - if (response.IsSuccessStatusCode) - { - successMessage = "Vault sealed."; - secrets = null; - tokens = null; - await RefreshStatus(); - } - else - { - errorMessage = "Failed to seal vault."; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isSealing = false; - } - } - - // ─── Unseal ────────────────────────────────────────────────────────── - - private async Task ProvideUnsealShare() - { - if (string.IsNullOrWhiteSpace(unsealShare)) - { - return; - } - - isUnsealing = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/vault/unseal", new - { - Share = unsealShare - }); - - if (response.IsSuccessStatusCode) - { - UnsealResponseDto? result = await response.Content.ReadFromJsonAsync(); - - if (result is not null && !result.Sealed) - { - successMessage = "Vault unsealed successfully."; - } - else - { - successMessage = $"Share accepted. {result?.Progress ?? ""}"; - } - - unsealShare = string.Empty; - await RefreshStatus(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to provide unseal share: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isUnsealing = false; - } - } - - // ─── Secrets CRUD ──────────────────────────────────────────────────── - - private async Task LoadSecrets() - { - try - { - SecretListDto? result = await Http.GetFromJsonAsync("api/vault/secrets"); - secrets = result?.Paths ?? new List(); - } - catch - { - secrets = new List(); - } - } - - private async Task PutSecret() - { - if (string.IsNullOrWhiteSpace(newSecretPath) || string.IsNullOrWhiteSpace(newSecretData)) - { - errorMessage = "Path and secret data are required."; - return; - } - - isPuttingSecret = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/vault/secrets", new - { - Path = newSecretPath, - Data = newSecretData - }); - - if (response.IsSuccessStatusCode) - { - successMessage = $"Secret stored at '{newSecretPath}'."; - newSecretPath = string.Empty; - newSecretData = string.Empty; - showCreateSecretForm = false; - await LoadSecrets(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to store secret: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isPuttingSecret = false; - } - } - - private async Task ViewSecret(string path) - { - if (viewingSecretPath == path) - { - viewingSecretPath = null; - viewedSecret = null; - return; - } - - isLoadingSecret = true; - - try - { - SecretValueDto? result = await Http.GetFromJsonAsync( - $"api/vault/secrets/{Uri.EscapeDataString(path)}"); - - viewedSecret = result; - viewingSecretPath = path; - } - catch (Exception ex) - { - errorMessage = $"Failed to read secret: {ex.Message}"; - } - finally - { - isLoadingSecret = false; - } - } - - private async Task DeleteSecret(string path) - { - isDeletingSecret = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync( - $"api/vault/secrets/{Uri.EscapeDataString(path)}"); - - if (response.IsSuccessStatusCode) - { - successMessage = $"Secret '{path}' deleted."; - - if (viewingSecretPath == path) - { - viewingSecretPath = null; - viewedSecret = null; - } - - await LoadSecrets(); - } - else - { - errorMessage = "Failed to delete secret."; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isDeletingSecret = false; - } - } - - // ─── Service Tokens ────────────────────────────────────────────────── - - private async Task LoadTokens() - { - try - { - TokenListDto? result = await Http.GetFromJsonAsync("api/vault/tokens"); - tokens = result?.Tokens ?? new List(); - } - catch - { - tokens = new List(); - } - } - - private void AddTokenPolicy() - { - newTokenPolicies.Add(new PolicyFormModel()); - } - - private async Task CreateToken() - { - if (string.IsNullOrWhiteSpace(newTokenName)) - { - errorMessage = "Token name is required."; - return; - } - - isCreatingToken = true; - errorMessage = null; - - try - { - List policies = newTokenPolicies - .Where(p => !string.IsNullOrWhiteSpace(p.PathPrefix)) - .Select(p => (object)new - { - PathPrefix = p.PathPrefix, - AllowedOperations = BuildOperationFlags(p) - }) - .ToList(); - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/vault/tokens", new - { - Name = newTokenName, - Policies = policies - }); - - if (response.IsSuccessStatusCode) - { - CreateTokenResponseDto? result = await response.Content.ReadFromJsonAsync(); - generatedToken = result?.Token; - successMessage = $"Token '{newTokenName}' created."; - newTokenName = string.Empty; - newTokenPolicies = new List { new() }; - showCreateTokenForm = false; - await LoadTokens(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to create token: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isCreatingToken = false; - } - } - - private async Task RevokeToken(string tokenName) - { - isRevokingToken = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/vault/tokens/revoke", new - { - Token = tokenName - }); - - if (response.IsSuccessStatusCode) - { - successMessage = $"Token '{tokenName}' revoked."; - await LoadTokens(); - } - else - { - errorMessage = "Failed to revoke token."; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isRevokingToken = false; - } - } - - private static int BuildOperationFlags(PolicyFormModel p) - { - int flags = 0; - - if (p.Read) - { - flags |= 1; - } - - if (p.Write) - { - flags |= 2; - } - - if (p.Delete) - { - flags |= 4; - } - - if (p.ListPaths) - { - flags |= 8; - } - - return flags; - } - - // ─── DTOs ──────────────────────────────────────────────────────────── - - private record VaultStatusDto(bool IsInitialized, bool IsSealed); - - private record InitializeResponseDto(List Shares, string Message); - - private record UnsealResponseDto(bool Sealed, string? Progress); - - private record SecretListDto(List Paths); - - private record SecretValueDto(string Path, string Data, int Version); - - private record TokenListDto(List Tokens, int Count); - - private record TokenInfoDto(string Name, DateTimeOffset CreatedAt, List Policies); - - private record CreateTokenResponseDto(string Token, string Name, string Message); - - // ─── Form Models ───────────────────────────────────────────────────── - - private class PolicyFormModel - { - public string PathPrefix { get; set; } = string.Empty; - public bool Read { get; set; } = true; - public bool Write { get; set; } - public bool Delete { get; set; } - public bool ListPaths { get; set; } = true; - } -} diff --git a/src/EntKube.Web.Client/Pages/Services.razor b/src/EntKube.Web.Client/Pages/Services.razor deleted file mode 100644 index 88db92c..0000000 --- a/src/EntKube.Web.Client/Pages/Services.razor +++ /dev/null @@ -1,328 +0,0 @@ -@page "/services" -@inject HttpClient Http -@rendermode InteractiveAuto - -Services - -

Provisioned Services

-

Manage shared Kubernetes applications deployed across your clusters. Provision MinIO, CloudNativePG, Keycloak, and more.

- -@if (errorMessage is not null) -{ - -} - -
- -
- -@if (showProvisionForm) -{ -
-
-
Provision New Service
- -
- - - - - - - -
-
- - -
-
- - -
-
- - @if (clusters is null) - { -

Loading clusters...

- } - else if (clusters.Count == 0) - { -
No clusters available. Register a cluster first.
- } - else - { - - - @foreach (ClusterSummaryDto cluster in clusters) - { - - } - - } -
- - -
-
-
-} - -@if (services is null) -{ -

Loading services...

-} -else if (services.Count == 0) -{ -
- No services provisioned yet. Click "Provision Service" to deploy your first shared service. -
-} -else -{ - - - - - - - - - - - - - @foreach (ServiceSummaryDto service in services) - { - - - - - - - - - } - -
NameTypeNamespaceCurrent StateDesired StateActions
@service.Name - @service.ServiceType - @service.Namespace - - @service.CurrentState - - @service.DesiredState - @if (service.DesiredState != "Decommissioned") - { - - } - else - { - Decommissioning... - } -
-} - -@code { - private List? services; - private List? clusters; - private ServiceFormModel newService = new(); - private bool showProvisionForm; - private bool isSubmitting; - private bool isDecommissioning; - private string? errorMessage; - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadServices(); - } - - private async Task LoadServices() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>("api/services"); - - if (response is not null && response.Success) - { - services = response.Data ?? new List(); - } - else - { - errorMessage = response?.Error ?? "Failed to load services."; - services = new List(); - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load services: {ex.Message}"; - services = new List(); - } - } - - private async Task LoadClusters() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>("api/clusters"); - - if (response is not null && response.Success) - { - clusters = response.Data ?? new List(); - } - else - { - clusters = new List(); - } - } - catch - { - clusters = new List(); - } - } - - private async Task ShowProvisionForm() - { - showProvisionForm = true; - newService = new ServiceFormModel(); - await LoadClusters(); - } - - private void CancelProvision() - { - showProvisionForm = false; - } - - private async Task ProvisionService() - { - if (string.IsNullOrWhiteSpace(newService.ServiceType) - || string.IsNullOrWhiteSpace(newService.Name) - || string.IsNullOrWhiteSpace(newService.Namespace) - || string.IsNullOrWhiteSpace(newService.ClusterId)) - { - errorMessage = "All fields are required."; - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - ProvisionServiceRequest request = new( - Guid.Parse(newService.ClusterId), - newService.ServiceType, - newService.Name, - newService.Namespace); - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/services", request); - - if (response.IsSuccessStatusCode) - { - showProvisionForm = false; - await LoadServices(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to provision service."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to provision service: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task DecommissionService(Guid id) - { - isDecommissioning = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/services/{id}/decommission", null); - - if (response.IsSuccessStatusCode) - { - await LoadServices(); - } - else - { - errorMessage = "Failed to decommission service."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to decommission service: {ex.Message}"; - } - finally - { - isDecommissioning = false; - } - } - - private static string GetStateBadgeClass(string state) => state switch - { - "Running" => "bg-success", - "Pending" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Decommissioned" => "bg-secondary", - _ => "bg-secondary" - }; - - private record ApiResponse - { - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - } - - private record ServiceSummaryDto( - Guid Id, - Guid ClusterId, - string ServiceType, - string Name, - string Namespace, - string CurrentState, - string DesiredState); - - private record ClusterSummaryDto( - Guid Id, - string Name, - string ApiServerUrl, - string Status, - DateTimeOffset? LastHealthCheckAt); - - private record ProvisionServiceRequest(Guid ClusterId, string ServiceType, string Name, string Namespace); - - private class ServiceFormModel - { - public string ServiceType { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string Namespace { get; set; } = string.Empty; - public string ClusterId { get; set; } = string.Empty; - } -} diff --git a/src/EntKube.Web.Client/Pages/TenantDetail.razor b/src/EntKube.Web.Client/Pages/TenantDetail.razor deleted file mode 100644 index d869df2..0000000 --- a/src/EntKube.Web.Client/Pages/TenantDetail.razor +++ /dev/null @@ -1,2607 +0,0 @@ -@page "/tenants/{TenantId:guid}" -@using YamlDotNet.Serialization -@using YamlDotNet.Serialization.NamingConventions -@inject HttpClient Http -@inject NavigationManager Navigation -@rendermode InteractiveAuto - -Tenant — @(tenant?.Name ?? "Loading...") - - - -@if (errorMessage is not null) -{ - -} - -@if (tenant is null) -{ -

Loading tenant...

-} -else -{ - @* ─── Tenant Header ─────────────────────────────────────────────────── *@ - -
-
-

@tenant.Name

-
- Slug: @tenant.Slug - @tenant.Status - @tenant.MemberCount member(s) - Created @tenant.CreatedAt.ToString("yyyy-MM-dd") -
-
-
- - @* ─── Tabs ──────────────────────────────────────────────────────────── *@ - - - - @* ═══════════════════════════════════════════════════════════════════════ - OVERVIEW TAB — Resource dashboard with aggregate metrics - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "overview") - { - @* ─── Metrics Row ─── *@ - -
-
-
-
- Environments - @(environments?.Count ?? 0) - @if (environments is not null && environments.Count > 0) - { - - @environments.Count(e => e.Status == "Active") active - - } -
-
-
-
-
-
- Customers - @(customers?.Count ?? 0) - @if (customers is not null && customers.Count > 0) - { - - @customers.Count(c => c.Status == "Active") active - - } -
-
-
-
-
-
- Clusters - @(clusters?.Count ?? 0) - @if (clusters is not null) - { - - @clusters.Count(c => c.Status == "Connected") connected - - } -
-
-
-
-
-
- PG Clusters - @(tenantPgClusters?.Count ?? 0) - @if (tenantPgClusters is not null && tenantPgClusters.Count > 0) - { - int totalInstances = tenantPgClusters.Sum(p => p.Instances); - @totalInstances instance(s) - } -
-
-
-
-
-
- Databases - @(tenantPgClusters?.Sum(p => p.Databases.Count) ?? 0) -
-
-
-
-
-
- MinIO Tenants - @(tenantMinioTenants?.Count ?? 0) -
-
-
-
-
-
- Keycloak Realms - @(tenantKeycloakRealms?.Count ?? 0) -
-
-
-
-
-
- CAs - @(tenantCAs?.Count ?? 0) - @if (tenantCAs is not null) - { - int expiringSoon = tenantCAs.Count(c => c.NotAfter.HasValue && c.NotAfter < DateTimeOffset.UtcNow.AddDays(90)); - - @if (expiringSoon > 0) - { - @expiringSoon expiring soon - } - } -
-
-
-
- - @* ─── Cluster Status Summary ─── *@ - - @if (clusters is not null && clusters.Count > 0) - { -
-
Cluster Status
-
- - - - - - - - - - - - - - - @foreach (ClusterSummaryDto c in clusters) - { - int pgCount = tenantPgClusters?.Count(p => p.ClusterId == c.Id) ?? 0; - int dbCount = tenantPgClusters?.Where(p => p.ClusterId == c.Id).Sum(p => p.Databases.Count) ?? 0; - int minioCount = tenantMinioTenants?.Count(m => m.ClusterId == c.Id) ?? 0; - int kcCount = tenantKeycloakRealms?.Count(k => k.ClusterId == c.Id) ?? 0; - int caCount = tenantCAs?.Count(ca => ca.ClusterId == c.Id) ?? 0; - - - - - - - - - - - - } - -
ClusterStatusProviderPG ClustersDatabasesMinIOKeycloakCAs
@c.Name@c.Status - @if (c.Provider is not null and not "None") - { - @c.Provider - } - else - { - - } - @pgCount@dbCount@minioCount@kcCount@caCount
-
-
- } - - @* ─── Recent alerts / warnings ─── *@ - - @if (BuildWarnings().Count > 0) - { -
-
-
Attention Needed
-
-
-
    - @foreach (string warning in BuildWarnings()) - { -
  • @warning
  • - } -
-
-
- } - } @* end overview tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - ENVIRONMENTS TAB — Manage deployment stages (dev, test, prod) - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "environments") - { -
-

Environments

- -
- - @if (environments is null) - { -
Loading environments...
- } - else if (environments.Count == 0) - { -
- No environments created yet. Create environments like "dev", "test", and "prod" to organize your clusters and services. -
- } - else - { -
- @foreach (EnvironmentDto env in environments) - { - int envClusterCount = clusters?.Count ?? 0; - -
-
-
-
- @env.Name - @env.Status -
- @env.Slug -
-
- Created @env.CreatedAt.ToString("yyyy-MM-dd") -
-
-
- } -
- } - - @if (showCreateEnvironmentForm) - { -
-
-
Create New Environment
- -
-
- - -
-
- - -
-
-
- - -
-
-
-
- } - } @* end environments tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - CUSTOMERS TAB — Manage teams/organizations within the tenant - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "customers") - { -
-

Customers

- -
- - @if (customers is null) - { -
Loading customers...
- } - else if (customers.Count == 0) - { -
- No customers onboarded yet. Customers represent teams or organizations that consume infrastructure within this tenant. -
- } - else - { -
-
- - - - - - - - - - - - @foreach (CustomerDto cust in customers) - { - - - - - - - - } - -
NameSlugStatusCreated
@cust.Name@cust.Slug@cust.Status@cust.CreatedAt.ToString("yyyy-MM-dd") - - Apps - -
-
-
- } - - @if (showCreateCustomerForm) - { -
-
-
Add New Customer
- -
-
- - -
-
- - -
-
-
- - -
-
-
-
- } - } @* end customers tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - CLUSTERS TAB — Rich cluster cards with health info - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "clusters") - { -
-

Clusters

- -
- - @if (clusters is null) - { -
Loading clusters...
- } - else if (clusters.Count == 0) - { -
- No clusters registered for this tenant yet. Register a Kubernetes cluster to get started. -
- } - else - { -
- @foreach (ClusterSummaryDto c in clusters) - { - int pgCount = tenantPgClusters?.Count(p => p.ClusterId == c.Id) ?? 0; - int dbCount = tenantPgClusters?.Where(p => p.ClusterId == c.Id).Sum(p => p.Databases.Count) ?? 0; - int minioCount = tenantMinioTenants?.Count(m => m.ClusterId == c.Id) ?? 0; - int kcCount = tenantKeycloakRealms?.Count(k => k.ClusterId == c.Id) ?? 0; - int caCount = tenantCAs?.Count(ca => ca.ClusterId == c.Id) ?? 0; - -
-
-
-
- @c.Name - @c.Status -
- -
-
-
-
- API Server - @c.ApiServerUrl -
-
- Provider - @if (c.Provider is not null and not "None") - { - @c.Provider - } - else - { - - } -
-
- Last Check - @(c.LastHealthCheckAt?.ToString("g") ?? "Never") -
-
-
- - @pgCount PG · @dbCount DB - - - @minioCount MinIO - - - @kcCount Keycloak - - - @caCount CAs - -
-
-
-
- } -
- } - - @* ─── Register Cluster Form ─── *@ - - @if (showRegisterForm) - { -
-
-
Register New Cluster
- -
- - -
-
- - -
-
- - -
- @if (kubeConfigParseError is not null) - { -
@kubeConfigParseError
- } - @if (availableContexts.Count > 1) - { -
- - -
- } - @if (!string.IsNullOrEmpty(newCluster.ApiServerUrl)) - { -
- - -
- } - - -
-
-
- } - } @* end clusters tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - DATABASES TAB — Aggregate CNPG clusters across all K8s clusters - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "databases") - { -

PostgreSQL Clusters

- - @if (tenantPgClusters is null) - { -
Loading databases...
- } - else if (tenantPgClusters.Count == 0) - { -
- No PostgreSQL clusters found across this tenant's clusters. - Go to a cluster detail page to create one. -
- } - else - { - @* ─── Summary metrics ─── *@ - -
-
-
-
- PG Clusters - @tenantPgClusters.Count -
-
-
-
-
-
- Total Instances - @tenantPgClusters.Sum(p => p.Instances) -
-
-
-
-
-
- Total Databases - @tenantPgClusters.Sum(p => p.Databases.Count) -
-
-
-
-
-
- K8s Clusters - @tenantPgClusters.Select(p => p.ClusterId).Distinct().Count() -
-
-
-
- - @* ─── PG cluster table ─── *@ - -
-
- - - - - - - - - - - - - - - - @foreach (TenantPgClusterSummary pg in tenantPgClusters) - { - - - - - - - - - - - - } - -
NameClusterNamespaceVersionInstancesStorageDatabasesBackupStatus
- @pg.Name - @pg.ClusterName@pg.NamespacePG @pg.PostgresVersion@pg.Instances@pg.StorageSize - @if (pg.Databases.Count > 0) - { - @pg.Databases.Count db(s) - } - else - { - 0 - } - - @if (pg.BackupSchedule is not null) - { - @pg.BackupSchedule - } - else - { - Not configured - } - @pg.Status
-
-
- } - } @* end databases tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - SERVICES TAB — MinIO + Keycloak aggregate - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "services") - { - @* ─── MinIO Tenants ─── *@ - -

MinIO Object Storage

- - @if (tenantMinioTenants is null) - { -
Loading MinIO tenants...
- } - else if (tenantMinioTenants.Count == 0) - { -
-
- No MinIO tenants deployed across this tenant's clusters. -
-
- } - else - { -
-
- - - - - - - - - - - - - - - @foreach (TenantMinioSummary minio in tenantMinioTenants) - { - - - - - - - - - - - } - -
NameClusterNamespaceEndpointCapacityPoolsServersStatus
- @minio.Name - @minio.ClusterName@minio.Namespace@minio.Endpoint@minio.TotalCapacity@minio.PoolCount@minio.TotalServers@minio.Status
-
-
- } - - @* ─── Keycloak Realms ─── *@ - -

Keycloak Identity Realms

- - @if (tenantKeycloakRealms is null) - { -
Loading Keycloak realms...
- } - else if (tenantKeycloakRealms.Count == 0) - { -
-
- No Keycloak realms deployed across this tenant's clusters. -
-
- } - else - { -
-
- - - - - - - - - - - - @foreach (TenantKeycloakSummary kc in tenantKeycloakRealms) - { - - - - - - - - } - -
RealmClusterIdentity ProvidersOrganizationsStatus
- @kc.Name - @kc.ClusterName - @if (kc.IdentityProviderCount > 0) - { - @kc.IdentityProviderCount IdP(s) - } - else - { - 0 - } - - @if (kc.OrganizationCount > 0) - { - @kc.OrganizationCount org(s) - } - else - { - 0 - } - @kc.Status
-
-
- } - } @* end services tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - CERTIFICATES TAB — Aggregate CAs across clusters - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "certificates") - { -
-

Certificate Authorities

- @if (tenantCAs is not null && tenantCAs.Count > 0) - { - @tenantCAs.Count CA(s) across @tenantCAs.Select(c => c.ClusterName).Distinct().Count() cluster(s) - } -
- - @if (tenantCAs is null) - { -
Loading CAs...
- } - else if (tenantCAs.Count == 0) - { -
-
- No Certificate Authorities found across this tenant's clusters. -
-
- } - else - { - @* ─── Summary badges ─── *@ - -
- - @tenantCAs.Count(c => c.Status == "Ready") Ready - - - @tenantCAs.Count(c => c.Type == "Internal") Internal - - - @tenantCAs.Count(c => c.Type == "Domain") Domain - - - @tenantCAs.Count(c => c.Type == "LetsEncrypt") Let's Encrypt - - @if (tenantCAs.Count(c => c.NotAfter.HasValue && c.NotAfter < DateTimeOffset.UtcNow.AddDays(90)) > 0) - { - @tenantCAs.Count(c => c.NotAfter.HasValue && c.NotAfter < DateTimeOffset.UtcNow.AddDays(90)) expiring within 90d - } -
- -
-
- - - - - - - - - - - - - - @foreach (TenantCaSummary ca in tenantCAs) - { - - - - - - - - - - } - -
NameTypeClusterStatusDomainsTrust BundleExpires
- @ca.Name - @if (ca.IsExternal) - { - External - } - @ca.Type@ca.ClusterName@ca.Status - @if (ca.Domains.Count > 0) - { - @string.Join(", ", ca.Domains) - } - else - { - - } - - @if (ca.InTrustBundle) - { - - } - else - { - - } - - @if (ca.NotAfter is not null) - { - - @ca.NotAfter.Value.ToString("yyyy-MM-dd") - - } - else - { - - } -
-
-
- } - } @* end certificates tab *@ - - @* ═══════════════════════════════════════════════════════════════════════ - SECRETS TAB — Tenant-scoped vault: initialize, seal/unseal, manage secrets - ═══════════════════════════════════════════════════════════════════════ *@ - - @if (activeTab == "secrets") - { - @if (vaultStatus is null) - { -
Loading vault status...
- } - else - { - @* ─── Vault Status Card ─── *@ - -
-
-
-
-
Vault Status
- @if (!vaultStatus.IsInitialized) - { -

Not Initialized

-

The vault has not been initialized for this tenant.

- } - else if (vaultStatus.IsSealed) - { -

Sealed

-

The vault is sealed. Secrets cannot be accessed.

- } - else - { -

Unsealed

-

The vault is ready to serve secrets.

- } -
-
-
-
-
-
-
Secrets
-

@(vaultSecrets?.Count ?? 0)

-

Stored paths

-
-
-
-
-
-
-
Service Tokens
-

@(vaultTokens?.Count ?? 0)

-

Active tokens

-
-
-
-
- - @* ─── Initialize Vault ─── *@ - - @if (!vaultStatus.IsInitialized) - { -
-
-
Initialize Vault
-

Generate the master encryption key and create Shamir recovery shares. This can only be done once per tenant.

-
-
- - -
Number of Shamir shares to generate
-
-
- - -
Shares needed to reconstruct the master key
-
-
- -
-
- } - - @* ─── Recovery Shares (shown once) ─── *@ - - @if (recoveryShares is not null) - { -
-
-
Recovery Shares
-

These are shown ONCE. Store them securely in separate locations.

-
- - - - @for (int i = 0; i < recoveryShares.Count; i++) - { - - } - -
Share #Value
@(i + 1)@recoveryShares[i]
-
- -
-
- } - - @* ─── Seal/Unseal Controls ─── *@ - - @if (vaultStatus.IsInitialized) - { -
-
-
Vault Controls
-
- @if (!vaultStatus.IsSealed) - { - - } - else - { -
-
- - -
- -
- } - -
-
-
- } - - @* ─── Secrets & Tokens (when unsealed) ─── *@ - - @if (vaultStatus.IsInitialized && !vaultStatus.IsSealed) - { - - - @if (vaultSubTab == "secrets") - { -
-
Stored Secrets
- -
- - @if (showCreateSecretForm) - { -
-
-
Create / Update Secret
-
-
- - -
-
-
- - -
-
- - -
-
-
- } - - @if (vaultSecrets is null) - { -
Loading...
- } - else if (vaultSecrets.Count == 0) - { -
No secrets stored yet.
- } - else - { -
- @foreach (string path in vaultSecrets) - { -
-
@path
-
- - -
-
- @if (viewingSecretPath == path && viewedSecret is not null) - { -
-
- Secret Value (v@(viewedSecret.Version)) - -
-
@viewedSecret.Data
-
- } - } -
- } - } - - @if (vaultSubTab == "tokens") - { -
-
Service Tokens
- -
- - @if (showCreateTokenForm) - { -
-
-
Create Service Token
-
-
- - -
-
-
- - @for (int i = 0; i < newTokenPolicies.Count; i++) - { - int index = i; -
-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- @if (newTokenPolicies.Count > 1) - { - - } -
-
- } - -
-
- - -
-
-
- } - - @if (generatedToken is not null) - { -
- New Token Created: Copy this token now — it will not be shown again. -
@generatedToken
- -
- } - - @if (vaultTokens is null) - { -
Loading...
- } - else if (vaultTokens.Count == 0) - { -
No service tokens created yet.
- } - else - { - - - - @foreach (TokenInfoDto token in vaultTokens) - { - - - - - - - } - -
NameCreatedPoliciesActions
@token.Name@token.CreatedAt.ToString("g")@foreach (string p in token.Policies) { @p }
- } - } - } - } - } @* end secrets tab *@ -} - -@code { - [Parameter] - public Guid TenantId { get; set; } - - // ─── Core state ─────────────────────────────────────────────────────── - - private TenantDetailDto? tenant; - private List? clusters; - private string activeTab = "overview"; - private string? errorMessage; - - // ─── Cluster registration state ─────────────────────────────────────── - - private ClusterFormModel newCluster = new(); - private List availableContexts = new(); - private string? kubeConfigParseError; - private bool showRegisterForm; - private bool isSubmitting; - private bool isDeleting; - - // ─── Aggregate data across clusters ─────────────────────────────────── - - private List? environments; - private List? customers; - private List? tenantCAs; - private List? tenantPgClusters; - private List? tenantMinioTenants; - private List? tenantKeycloakRealms; - - // ─── Environment / Customer form state ──────────────────────────────── - - private bool showCreateEnvironmentForm; - private bool showCreateCustomerForm; - private EnvironmentFormModel newEnvironment = new(); - private CustomerFormModel newCustomer = new(); - - // ─── Vault state ────────────────────────────────────────────────────── - - private VaultStatusDto? vaultStatus; - private string vaultSubTab = "secrets"; - private int initTotalShares = 5; - private int initThreshold = 3; - private bool isVaultInitializing; - private List? recoveryShares; - private bool isVaultSealing; - private bool isVaultUnsealing; - private string unsealShare = string.Empty; - private List? vaultSecrets; - private bool showCreateSecretForm; - private string newSecretPath = string.Empty; - private string newSecretData = string.Empty; - private bool isPuttingSecret; - private string? viewingSecretPath; - private SecretValueDto? viewedSecret; - private List? vaultTokens; - private bool showCreateTokenForm; - private string newTokenName = string.Empty; - private List newTokenPolicies = new() { new() }; - private bool isCreatingToken; - private string? generatedToken; - - // ─── Lifecycle ──────────────────────────────────────────────────────── - - protected override async Task OnInitializedAsync() - { - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadTenant(); - await LoadClusters(); - - // Load environments and customers immediately — they're core to the - // tenant detail page and needed on all tabs. - - await Task.WhenAll( - LoadEnvironments(), - LoadCustomers(), - LoadTenantCAs(), - LoadTenantPgClusters(), - LoadTenantMinioTenants(), - LoadTenantKeycloakRealms()); - } - - /// - /// When the user switches tabs, load the data for that tab lazily - /// if it hasn't been loaded yet. - /// - private void SetActiveTab(string tab) - { - activeTab = tab; - - // Trigger lazy loads for data that hasn't been fetched yet. - - if (tab == "environments" && environments is null) - { - _ = LoadEnvironments(); - } - - if (tab == "customers" && customers is null) - { - _ = LoadCustomers(); - } - - if (tab == "databases" && tenantPgClusters is null) - { - _ = LoadTenantPgClusters(); - } - - if (tab == "services") - { - if (tenantMinioTenants is null) - { - _ = LoadTenantMinioTenants(); - } - - if (tenantKeycloakRealms is null) - { - _ = LoadTenantKeycloakRealms(); - } - } - - if (tab == "certificates" && tenantCAs is null) - { - _ = LoadTenantCAs(); - } - - if (tab == "secrets" && vaultStatus is null) - { - _ = LoadVaultStatus(); - } - } - - // ─── Data loading ───────────────────────────────────────────────────── - - private async Task LoadTenant() - { - try - { - ApiResponse? response = - await Http.GetFromJsonAsync>($"api/tenants/{TenantId}"); - - if (response is not null && response.Success) - { - tenant = response.Data; - } - else - { - errorMessage = response?.Error ?? "Failed to load tenant."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load tenant: {ex.Message}"; - } - } - - private async Task LoadClusters() - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters?tenantId={TenantId}"); - - if (response is not null && response.Success) - { - clusters = response.Data ?? new List(); - } - else - { - clusters = new List(); - } - } - catch - { - clusters = new List(); - } - - } - - /// - /// Loads Certificate Authorities from every cluster belonging to this tenant, - /// combining them into a single aggregated list. - /// - private async Task LoadTenantCAs() - { - if (clusters is null || clusters.Count == 0) - { - tenantCAs = new(); - StateHasChanged(); - return; - } - - List allCAs = new(); - - List? CAs)>> tasks = clusters - .Select(async cluster => - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/clusters/{cluster.Id}/certificates/cas"); - - return (cluster.Id, cluster.Name, response?.Success == true ? response.Data : null); - } - catch - { - return (cluster.Id, cluster.Name, (List?)null); - } - }) - .ToList(); - - (Guid ClusterId, string ClusterName, List? CAs)[] results = await Task.WhenAll(tasks); - - foreach ((Guid ClusterId, string ClusterName, List? CAs) result in results) - { - if (result.CAs is null) - { - continue; - } - - foreach (TenantCaDto ca in result.CAs) - { - allCAs.Add(new TenantCaSummary( - ca.Name, ca.Type, result.ClusterId, result.ClusterName, - ca.Domains, ca.IsExternal, ca.InTrustBundle, - ca.Status, ca.NotAfter)); - } - } - - tenantCAs = allCAs; - StateHasChanged(); - } - - /// - /// Loads PostgreSQL clusters from every K8s cluster belonging to this tenant. - /// - private async Task LoadTenantPgClusters() - { - if (clusters is null || clusters.Count == 0) - { - tenantPgClusters = new(); - StateHasChanged(); - return; - } - - List allPg = new(); - - List? PgClusters)>> tasks = clusters - .Select(async cluster => - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/postgres-clusters?clusterId={cluster.Id}"); - - return (cluster.Id, cluster.Name, response?.Success == true ? response.Data : null); - } - catch - { - return (cluster.Id, cluster.Name, (List?)null); - } - }) - .ToList(); - - (Guid ClusterId, string ClusterName, List? PgClusters)[] results = await Task.WhenAll(tasks); - - foreach ((Guid ClusterId, string ClusterName, List? PgClusters) result in results) - { - if (result.PgClusters is null) - { - continue; - } - - foreach (TenantPgClusterDto pg in result.PgClusters) - { - allPg.Add(new TenantPgClusterSummary( - pg.Id, result.ClusterId, result.ClusterName, - pg.Name, pg.Namespace, pg.PostgresVersion, - pg.Instances, pg.StorageSize, pg.Status, - pg.Databases, pg.BackupSchedule)); - } - } - - tenantPgClusters = allPg; - StateHasChanged(); - } - - /// - /// Loads MinIO tenants from every K8s cluster belonging to this tenant. - /// - private async Task LoadTenantMinioTenants() - { - if (clusters is null || clusters.Count == 0) - { - tenantMinioTenants = new(); - StateHasChanged(); - return; - } - - List allMinio = new(); - - List? Tenants)>> tasks = clusters - .Select(async cluster => - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/minio-tenants/cluster/{cluster.Id}"); - - return (cluster.Id, cluster.Name, response?.Success == true ? response.Data : null); - } - catch - { - return (cluster.Id, cluster.Name, (List?)null); - } - }) - .ToList(); - - (Guid ClusterId, string ClusterName, List? Tenants)[] results = await Task.WhenAll(tasks); - - foreach ((Guid ClusterId, string ClusterName, List? Tenants) result in results) - { - if (result.Tenants is null) - { - continue; - } - - foreach (TenantMinioDto m in result.Tenants) - { - allMinio.Add(new TenantMinioSummary( - m.Id, result.ClusterId, result.ClusterName, - m.Name, m.Namespace, m.Endpoint, m.TotalCapacity, - m.PoolCount, m.TotalServers, m.Status)); - } - } - - tenantMinioTenants = allMinio; - StateHasChanged(); - } - - /// - /// Loads Keycloak realms from every K8s cluster belonging to this tenant. - /// - private async Task LoadTenantKeycloakRealms() - { - if (clusters is null || clusters.Count == 0) - { - tenantKeycloakRealms = new(); - StateHasChanged(); - return; - } - - List allKc = new(); - - List? Realms)>> tasks = clusters - .Select(async cluster => - { - try - { - ApiResponse>? response = - await Http.GetFromJsonAsync>>($"api/keycloak-realms/cluster/{cluster.Id}"); - - return (cluster.Id, cluster.Name, response?.Success == true ? response.Data : null); - } - catch - { - return (cluster.Id, cluster.Name, (List?)null); - } - }) - .ToList(); - - (Guid ClusterId, string ClusterName, List? Realms)[] results = await Task.WhenAll(tasks); - - foreach ((Guid ClusterId, string ClusterName, List? Realms) result in results) - { - if (result.Realms is null) - { - continue; - } - - foreach (TenantKeycloakDto kc in result.Realms) - { - allKc.Add(new TenantKeycloakSummary( - kc.Id, result.ClusterId, result.ClusterName, - kc.Name, kc.Status, - kc.IdentityProviders?.Count ?? 0, - kc.Organizations?.Count ?? 0)); - } - } - - tenantKeycloakRealms = allKc; - StateHasChanged(); - } - - // ─── Cluster registration methods ───────────────────────────────────── - - private void ShowRegisterForm() - { - showRegisterForm = true; - newCluster = new ClusterFormModel(); - availableContexts = new(); - kubeConfigParseError = null; - } - - private void CancelRegister() - { - showRegisterForm = false; - } - - private void OnKubeConfigChanged() - { - availableContexts = new(); - kubeConfigParseError = null; - newCluster.ApiServerUrl = string.Empty; - newCluster.SelectedContext = string.Empty; - - if (string.IsNullOrWhiteSpace(newCluster.KubeConfig)) - { - return; - } - - try - { - IDeserializer deserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); - - KubeConfigYaml? config = deserializer.Deserialize(newCluster.KubeConfig); - - if (config?.Contexts is null || config.Contexts.Count == 0) - { - kubeConfigParseError = "No contexts found in the kubeconfig."; - return; - } - - if (config.Clusters is null || config.Clusters.Count == 0) - { - kubeConfigParseError = "No clusters found in the kubeconfig."; - return; - } - - Dictionary clusterServerMap = new(); - - foreach (KubeConfigClusterEntry entry in config.Clusters) - { - if (entry.Name is not null && entry.Cluster?.Server is not null) - { - clusterServerMap[entry.Name] = entry.Cluster.Server; - } - } - - foreach (KubeConfigContextEntry ctxEntry in config.Contexts) - { - if (ctxEntry.Name is not null && ctxEntry.Context?.Cluster is not null) - { - string serverUrl = clusterServerMap.GetValueOrDefault(ctxEntry.Context.Cluster, "unknown"); - availableContexts.Add(new KubeContextInfo(ctxEntry.Name, serverUrl)); - } - } - - if (availableContexts.Count == 0) - { - kubeConfigParseError = "Could not resolve any context to a cluster server URL."; - return; - } - - if (availableContexts.Count == 1) - { - newCluster.SelectedContext = availableContexts[0].Name; - newCluster.ApiServerUrl = availableContexts[0].ServerUrl; - } - } - catch (Exception ex) - { - kubeConfigParseError = $"Failed to parse kubeconfig: {ex.Message}"; - } - } - - private async Task RegisterCluster() - { - if (availableContexts.Count > 1 && !string.IsNullOrWhiteSpace(newCluster.SelectedContext)) - { - KubeContextInfo? selected = availableContexts.FirstOrDefault(c => c.Name == newCluster.SelectedContext); - - if (selected is not null) - { - newCluster.ApiServerUrl = selected.ServerUrl; - } - } - - if (string.IsNullOrWhiteSpace(newCluster.Name)) - { - errorMessage = "Cluster name is required."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.KubeConfig)) - { - errorMessage = "KubeConfig is required."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.ApiServerUrl)) - { - errorMessage = "Please paste a valid kubeconfig and select a context."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.SelectedContext)) - { - errorMessage = "Please select a context from the kubeconfig."; - return; - } - - if (string.IsNullOrWhiteSpace(newCluster.EnvironmentId)) - { - errorMessage = "An environment must be selected."; - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - RegisterClusterRequest request = new( - newCluster.Name, - newCluster.ApiServerUrl, - newCluster.KubeConfig, - TenantId, - newCluster.SelectedContext, - Guid.Parse(newCluster.EnvironmentId)); - - HttpResponseMessage response = await Http.PostAsJsonAsync("api/clusters", request); - - if (response.IsSuccessStatusCode) - { - showRegisterForm = false; - await LoadClusters(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to register cluster."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to register cluster: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task DeleteCluster(Guid id) - { - isDeleting = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync($"api/clusters/{id}"); - - if (response.IsSuccessStatusCode) - { - await LoadClusters(); - } - else - { - errorMessage = "Failed to delete cluster."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to delete cluster: {ex.Message}"; - } - finally - { - isDeleting = false; - } - } - - // ─── Badge helpers ──────────────────────────────────────────────────── - - // ─── Load Environments / Customers ──────────────────────────────────── - - private async Task LoadEnvironments() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>($"api/tenants/{TenantId}/environments"); - - if (response?.Success == true) - { - environments = response.Data ?? new(); - } - } - catch - { - environments = new(); - } - - StateHasChanged(); - } - - private async Task LoadCustomers() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>($"api/tenants/{TenantId}/customers"); - - if (response?.Success == true) - { - customers = response.Data ?? new(); - } - } - catch - { - customers = new(); - } - - StateHasChanged(); - } - - private async Task CreateEnvironment() - { - isSubmitting = true; - errorMessage = null; - - try - { - object request = new { newEnvironment.Name, newEnvironment.Slug }; - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/tenants/{TenantId}/environments", request); - - if (response.IsSuccessStatusCode) - { - showCreateEnvironmentForm = false; - newEnvironment = new(); - await LoadEnvironments(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to create environment."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to create environment: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private async Task CreateCustomer() - { - isSubmitting = true; - errorMessage = null; - - try - { - object request = new { newCustomer.Name, newCustomer.Slug }; - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/tenants/{TenantId}/customers", request); - - if (response.IsSuccessStatusCode) - { - showCreateCustomerForm = false; - newCustomer = new(); - await LoadCustomers(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to add customer."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to add customer: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - // ─── Badge helpers ──────────────────────────────────────────────────── - - private List BuildWarnings() - { - List warnings = new(); - - if (clusters is not null) - { - foreach (ClusterSummaryDto c in clusters.Where(c => c.Status == "Unreachable")) - { - warnings.Add($"Cluster '{c.Name}' is unreachable."); - } - } - - if (tenantPgClusters is not null) - { - foreach (TenantPgClusterSummary pg in tenantPgClusters.Where(p => p.Status == "Degraded")) - { - warnings.Add($"PostgreSQL cluster '{pg.Name}' on '{pg.ClusterName}' is degraded."); - } - } - - if (tenantCAs is not null) - { - foreach (TenantCaSummary ca in tenantCAs.Where(c => c.NotAfter.HasValue && c.NotAfter < DateTimeOffset.UtcNow.AddDays(30))) - { - warnings.Add($"CA '{ca.Name}' on '{ca.ClusterName}' expires {ca.NotAfter!.Value:yyyy-MM-dd}."); - } - } - - return warnings; - } - - private static string GetStatusBadgeClass(string status) => status switch - { - "Connected" => "bg-success", - "Pending" => "bg-warning text-dark", - "Unreachable" => "bg-danger", - _ => "bg-secondary" - }; - - private static string GetTenantStatusBadge(string status) => status switch - { - "Active" => "bg-success", - "Suspended" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - private static string PgStatusBadgeClass(string status) => status switch - { - "Running" or "Cluster in healthy state" => "bg-success", - "Provisioning" => "bg-warning text-dark", - "Degraded" => "bg-danger", - "Upgrading" => "bg-info", - _ => "bg-secondary" - }; - - private static string MinioStatusBadgeClass(string status) => status switch - { - "Initialized" or "Running" => "bg-success", - "Provisioning" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - private static string KcStatusBadgeClass(string status) => status switch - { - "Active" or "Running" => "bg-success", - "Provisioning" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - private static string CaTypeBadge(string type) => type switch - { - "Internal" => "bg-primary", - "Domain" => "bg-info", - "LetsEncrypt" => "bg-success", - _ => "bg-secondary" - }; - - private static string CaStatusBadge(string status) => status switch - { - "Ready" => "bg-success", - "NotReady" => "bg-danger", - "Trust-Only" => "bg-info", - _ => "bg-secondary" - }; - - // ─── DTOs ───────────────────────────────────────────────────────────── - - private record ApiResponse - { - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - } - - private record TenantDetailDto(Guid Id, string Name, string Slug, string Status, int MemberCount, DateTimeOffset CreatedAt); - - private record EnvironmentDto(Guid Id, Guid TenantId, string Name, string Slug, string Status, DateTimeOffset CreatedAt); - - private record CustomerDto(Guid Id, Guid TenantId, string Name, string Slug, string Status, DateTimeOffset CreatedAt); - - private record ClusterSummaryDto( - Guid Id, string Name, string ApiServerUrl, string Status, - string? Provider, Guid TenantId, DateTimeOffset? LastHealthCheckAt); - - private record RegisterClusterRequest(string Name, string ApiServerUrl, string KubeConfig, Guid TenantId, string ContextName, Guid EnvironmentId); - - private record KubeContextInfo(string Name, string ServerUrl); - - // ─── Kubeconfig parsing models ──────────────────────────────────────── - - private class KubeConfigYaml - { - public List? Clusters { get; set; } - public List? Contexts { get; set; } - } - - private class KubeConfigClusterEntry - { - public string? Name { get; set; } - public KubeConfigClusterData? Cluster { get; set; } - } - - private class KubeConfigClusterData - { - public string? Server { get; set; } - } - - private class KubeConfigContextEntry - { - public string? Name { get; set; } - public KubeConfigContextData? Context { get; set; } - } - - private class KubeConfigContextData - { - public string? Cluster { get; set; } - } - - private class ClusterFormModel - { - public string Name { get; set; } = string.Empty; - public string ApiServerUrl { get; set; } = string.Empty; - public string KubeConfig { get; set; } = string.Empty; - public string SelectedContext { get; set; } = string.Empty; - public string EnvironmentId { get; set; } = string.Empty; - } - - private class EnvironmentFormModel - { - public string Name { get; set; } = string.Empty; - public string Slug { get; set; } = string.Empty; - } - - private class CustomerFormModel - { - public string Name { get; set; } = string.Empty; - public string Slug { get; set; } = string.Empty; - } - - // ─── Vault methods ──────────────────────────────────────────────────── - - private async Task LoadVaultStatus() - { - try - { - VaultStatusDto? status = await Http.GetFromJsonAsync($"api/vault/{TenantId}/status"); - vaultStatus = status; - - if (vaultStatus is not null && vaultStatus.IsInitialized && !vaultStatus.IsSealed) - { - await Task.WhenAll(LoadVaultSecrets(), LoadVaultTokens()); - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load vault status: {ex.Message}"; - } - - StateHasChanged(); - } - - private async Task InitializeVault() - { - isVaultInitializing = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/vault/{TenantId}/init", new - { - TotalShares = initTotalShares, - Threshold = initThreshold - }); - - if (response.IsSuccessStatusCode) - { - InitializeResponseDto? result = await response.Content.ReadFromJsonAsync(); - recoveryShares = result?.Shares; - await LoadVaultStatus(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to initialize vault: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isVaultInitializing = false; - } - } - - private async Task SealVault() - { - isVaultSealing = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsync($"api/vault/{TenantId}/seal", null); - - if (response.IsSuccessStatusCode) - { - vaultSecrets = null; - vaultTokens = null; - await LoadVaultStatus(); - } - else - { - errorMessage = "Failed to seal vault."; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isVaultSealing = false; - } - } - - private async Task ProvideUnsealShare() - { - if (string.IsNullOrWhiteSpace(unsealShare)) - { - return; - } - - isVaultUnsealing = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/vault/{TenantId}/unseal", new - { - Share = unsealShare - }); - - if (response.IsSuccessStatusCode) - { - unsealShare = string.Empty; - await LoadVaultStatus(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to unseal: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isVaultUnsealing = false; - } - } - - private async Task LoadVaultSecrets() - { - try - { - SecretListDto? result = await Http.GetFromJsonAsync($"api/vault/{TenantId}/secrets"); - vaultSecrets = result?.Paths ?? new List(); - } - catch - { - vaultSecrets = new List(); - } - } - - private async Task PutSecret() - { - if (string.IsNullOrWhiteSpace(newSecretPath) || string.IsNullOrWhiteSpace(newSecretData)) - { - errorMessage = "Path and secret data are required."; - return; - } - - isPuttingSecret = true; - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync( - $"api/vault/{TenantId}/secrets/{Uri.EscapeDataString(newSecretPath)}", new - { - Data = newSecretData - }); - - if (response.IsSuccessStatusCode) - { - newSecretPath = string.Empty; - newSecretData = string.Empty; - showCreateSecretForm = false; - await LoadVaultSecrets(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to store secret: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isPuttingSecret = false; - } - } - - private async Task ViewSecret(string path) - { - if (viewingSecretPath == path) - { - viewingSecretPath = null; - viewedSecret = null; - return; - } - - try - { - SecretValueDto? result = await Http.GetFromJsonAsync( - $"api/vault/{TenantId}/secrets/{Uri.EscapeDataString(path)}"); - viewedSecret = result; - viewingSecretPath = path; - } - catch (Exception ex) - { - errorMessage = $"Failed to read secret: {ex.Message}"; - } - } - - private async Task DeleteSecret(string path) - { - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.DeleteAsync( - $"api/vault/{TenantId}/secrets/{Uri.EscapeDataString(path)}"); - - if (response.IsSuccessStatusCode) - { - if (viewingSecretPath == path) - { - viewingSecretPath = null; - viewedSecret = null; - } - - await LoadVaultSecrets(); - } - else - { - errorMessage = "Failed to delete secret."; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - } - - private async Task LoadVaultTokens() - { - try - { - TokenListDto? result = await Http.GetFromJsonAsync($"api/vault/{TenantId}/tokens"); - vaultTokens = result?.Tokens ?? new List(); - } - catch - { - vaultTokens = new List(); - } - } - - private async Task CreateToken() - { - if (string.IsNullOrWhiteSpace(newTokenName)) - { - errorMessage = "Token name is required."; - return; - } - - isCreatingToken = true; - errorMessage = null; - - try - { - List policies = newTokenPolicies - .Where(p => !string.IsNullOrWhiteSpace(p.PathPrefix)) - .Select(p => (object)new - { - PathPrefix = p.PathPrefix, - AllowedOperations = BuildOperationFlags(p) - }) - .ToList(); - - HttpResponseMessage response = await Http.PostAsJsonAsync($"api/vault/{TenantId}/tokens", new - { - Name = newTokenName, - Policies = policies - }); - - if (response.IsSuccessStatusCode) - { - CreateTokenResponseDto? result = await response.Content.ReadFromJsonAsync(); - generatedToken = result?.Token; - newTokenName = string.Empty; - newTokenPolicies = new List { new() }; - showCreateTokenForm = false; - await LoadVaultTokens(); - } - else - { - string body = await response.Content.ReadAsStringAsync(); - errorMessage = $"Failed to create token: {body}"; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - finally - { - isCreatingToken = false; - } - } - - private async Task RevokeToken(string tokenName) - { - errorMessage = null; - - try - { - HttpResponseMessage response = await Http.PostAsJsonAsync("api/vault/tokens/revoke", new - { - Token = tokenName - }); - - if (response.IsSuccessStatusCode) - { - await LoadVaultTokens(); - } - else - { - errorMessage = "Failed to revoke token."; - } - } - catch (Exception ex) - { - errorMessage = $"Error: {ex.Message}"; - } - } - - private static int BuildOperationFlags(PolicyFormModel p) - { - int flags = 0; - - if (p.Read) { flags |= 1; } - - if (p.Write) { flags |= 2; } - - if (p.Delete) { flags |= 4; } - - if (p.ListPaths) { flags |= 8; } - - return flags; - } - - // ─── Vault DTOs ─────────────────────────────────────────────────────── - - private record VaultStatusDto(bool IsInitialized, bool IsSealed); - private record InitializeResponseDto(List Shares); - private record SecretListDto(List Paths); - private record SecretValueDto(string Data, int Version); - private record TokenInfoDto(string Name, DateTimeOffset CreatedAt, List Policies); - private record TokenListDto(List Tokens); - private record CreateTokenResponseDto(string Token); - - private class PolicyFormModel - { - public string PathPrefix { get; set; } = string.Empty; - public bool Read { get; set; } - public bool Write { get; set; } - public bool Delete { get; set; } - public bool ListPaths { get; set; } - } - - // ─── CA DTOs ────────────────────────────────────────────────────────── - - private record TenantCaDto( - string Name, string Type, List Domains, bool IsExternal, - bool InTrustBundle, string Status, DateTimeOffset? NotAfter); - - private record TenantCaSummary( - string Name, string Type, Guid ClusterId, string ClusterName, - List Domains, bool IsExternal, bool InTrustBundle, - string Status, DateTimeOffset? NotAfter); - - // ─── PostgreSQL DTOs ────────────────────────────────────────────────── - - private record TenantPgClusterDto( - Guid Id, Guid ClusterId, string Name, string Namespace, - string PostgresVersion, int Instances, string StorageSize, - string Status, List Databases, - string? BackupSchedule, int? BackupRetentionDays, string? TargetVersion); - - private record TenantPgClusterSummary( - Guid Id, Guid ClusterId, string ClusterName, - string Name, string Namespace, string PostgresVersion, - int Instances, string StorageSize, string Status, - List Databases, string? BackupSchedule); - - // ─── MinIO DTOs ─────────────────────────────────────────────────────── - - private record TenantMinioDto( - Guid Id, Guid ClusterId, string Name, string Namespace, - string Endpoint, string TotalCapacity, int PoolCount, - int TotalServers, string Status); - - private record TenantMinioSummary( - Guid Id, Guid ClusterId, string ClusterName, - string Name, string Namespace, string Endpoint, - string TotalCapacity, int PoolCount, int TotalServers, string Status); - - // ─── Keycloak DTOs ──────────────────────────────────────────────────── - - private record TenantKeycloakDto( - Guid Id, Guid ClusterId, string Name, string Status, - List? IdentityProviders, - List? Organizations); - - private record TenantKeycloakIdpDto(string Alias, string DisplayName, string Type, bool Enabled); - - private record TenantKeycloakOrgDto(string Name); - - private record TenantKeycloakSummary( - Guid Id, Guid ClusterId, string ClusterName, - string Name, string Status, - int IdentityProviderCount, int OrganizationCount); -} diff --git a/src/EntKube.Web.Client/Pages/Tenants.razor b/src/EntKube.Web.Client/Pages/Tenants.razor deleted file mode 100644 index 6138fd7..0000000 --- a/src/EntKube.Web.Client/Pages/Tenants.razor +++ /dev/null @@ -1,214 +0,0 @@ -@page "/tenants" -@inject HttpClient Http -@rendermode InteractiveAuto - -Tenants - -

Tenants

-

Manage organizations on the platform. Each tenant has isolated access to clusters and provisioned services.

- -@if (errorMessage is not null) -{ - -} - -
- -
- -@if (showCreateForm) -{ -
-
-
Create New Tenant
- -
- - -
-
- - -
- - -
-
-
-} - -@if (tenants is null) -{ -

Loading tenants...

-} -else if (tenants.Count == 0) -{ -
- No tenants created yet. Click "Create Tenant" to add your first organization. -
-} -else -{ - - - - - - - - - - - - @foreach (TenantSummaryDto tenant in tenants) - { - - - - - - - - } - -
NameSlugStatusMembersCreated
@tenant.Name@tenant.Slug - - @tenant.Status - - @tenant.MemberCount@tenant.CreatedAt.ToString("g")
-} - -@code { - private List? tenants; - private TenantFormModel newTenant = new(); - private bool showCreateForm; - private bool isSubmitting; - private string? errorMessage; - - protected override async Task OnInitializedAsync() - { - // Skip during SSR prerender — the server-side HttpClient has no auth - // cookies and the loopback call to /api/tenants would get a 401. - - if (!RendererInfo.IsInteractive) - { - return; - } - - await LoadTenants(); - } - - private async Task LoadTenants() - { - try - { - ApiResponse>? response = await Http.GetFromJsonAsync>>("api/tenants"); - - if (response is not null && response.Success) - { - tenants = response.Data ?? new List(); - errorMessage = null; - } - else - { - errorMessage = response?.Error ?? "Failed to load tenants."; - tenants = new List(); - } - } - catch (Exception ex) - { - errorMessage = $"Failed to load tenants: {ex.Message}"; - tenants = new List(); - } - } - - private void ShowCreateForm() - { - showCreateForm = true; - newTenant = new TenantFormModel(); - } - - private void CancelCreate() - { - showCreateForm = false; - } - - private async Task CreateTenant() - { - if (string.IsNullOrWhiteSpace(newTenant.Name) || string.IsNullOrWhiteSpace(newTenant.Slug)) - { - errorMessage = "Organization name and slug are required."; - return; - } - - isSubmitting = true; - errorMessage = null; - - try - { - // Use a placeholder user ID for now — will come from auth context in production. - CreateTenantRequest request = new(newTenant.Name, newTenant.Slug, Guid.NewGuid()); - HttpResponseMessage response = await Http.PostAsJsonAsync("api/tenants", request); - - if (response.IsSuccessStatusCode) - { - showCreateForm = false; - await LoadTenants(); - } - else - { - ApiResponse? errorResponse = await response.Content.ReadFromJsonAsync>(); - errorMessage = errorResponse?.Error ?? "Failed to create tenant."; - } - } - catch (Exception ex) - { - errorMessage = $"Failed to create tenant: {ex.Message}"; - } - finally - { - isSubmitting = false; - } - } - - private static string GetStatusBadgeClass(string status) => status switch - { - "Active" => "bg-success", - "Suspended" => "bg-warning text-dark", - _ => "bg-secondary" - }; - - private record ApiResponse - { - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } - } - - private record TenantSummaryDto( - Guid Id, - string Name, - string Slug, - string Status, - int MemberCount, - DateTimeOffset CreatedAt); - - private record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId); - - private class TenantFormModel - { - public string Name { get; set; } = string.Empty; - public string Slug { get; set; } = string.Empty; - } -} diff --git a/src/EntKube.Web.Client/Pages/Weather.razor b/src/EntKube.Web.Client/Pages/Weather.razor deleted file mode 100644 index be6d06d..0000000 --- a/src/EntKube.Web.Client/Pages/Weather.razor +++ /dev/null @@ -1,63 +0,0 @@ -@page "/weather" - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@code { - private WeatherForecast[]? forecasts; - - protected override async Task OnInitializedAsync() - { - // Simulate asynchronous loading to demonstrate a loading indicator - await Task.Delay(500); - - var startDate = DateOnly.FromDateTime(DateTime.Now); - var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }).ToArray(); - } - - private class WeatherForecast - { - public DateOnly Date { get; set; } - public int TemperatureC { get; set; } - public string? Summary { get; set; } - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - } -} diff --git a/src/EntKube.Web.Client/Program.cs b/src/EntKube.Web.Client/Program.cs index da2ab3b..f118014 100644 --- a/src/EntKube.Web.Client/Program.cs +++ b/src/EntKube.Web.Client/Program.cs @@ -1,16 +1,17 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -var builder = WebAssemblyHostBuilder.CreateDefault(args); +namespace EntKube.Web.Client; -builder.Services.AddAuthorizationCore(); -builder.Services.AddCascadingAuthenticationState(); -builder.Services.AddAuthenticationStateDeserialization(); - -// Register HttpClient with the BFF base address so pages can call /api/* endpoints. -builder.Services.AddScoped(sp => new HttpClient +class Program { - BaseAddress = new Uri(builder.HostEnvironment.BaseAddress), - Timeout = TimeSpan.FromMinutes(15) -}); + static async Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); -await builder.Build().RunAsync(); + builder.Services.AddAuthorizationCore(); + builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddAuthenticationStateDeserialization(); + + await builder.Build().RunAsync(); + } +} diff --git a/src/EntKube.Web.Client/_Imports.razor b/src/EntKube.Web.Client/_Imports.razor index 67fc7d8..0c07c27 100644 --- a/src/EntKube.Web.Client/_Imports.razor +++ b/src/EntKube.Web.Client/_Imports.razor @@ -8,4 +8,3 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using EntKube.Web.Client -@using EntKube.Web.Client.Layout diff --git a/src/EntKube.Web/Components/Account/Shared/ManageLayout.razor b/src/EntKube.Web/Components/Account/Shared/ManageLayout.razor index 35bd7db..4625a04 100644 --- a/src/EntKube.Web/Components/Account/Shared/ManageLayout.razor +++ b/src/EntKube.Web/Components/Account/Shared/ManageLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase -@layout EntKube.Web.Client.Layout.MainLayout +@layout EntKube.Web.Components.Layout.MainLayout

Manage your account

diff --git a/src/EntKube.Web/Components/App.razor b/src/EntKube.Web/Components/App.razor index baf6723..5db165b 100644 --- a/src/EntKube.Web/Components/App.razor +++ b/src/EntKube.Web/Components/App.razor @@ -12,22 +12,14 @@ - + - + - -@code { - [CascadingParameter] - private HttpContext HttpContext { get; set; } = default!; - - private IComponentRenderMode? PageRenderMode => - HttpContext.AcceptsInteractiveRouting() ? InteractiveAuto : null; -} diff --git a/src/EntKube.Web/Components/Clusters/CertificateProxyEndpoints.cs b/src/EntKube.Web/Components/Clusters/CertificateProxyEndpoints.cs deleted file mode 100644 index 57f4b08..0000000 --- a/src/EntKube.Web/Components/Clusters/CertificateProxyEndpoints.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Clusters; - -/// -/// BFF proxy endpoints for certificate management. Forwards requests from -/// the Blazor frontend to the Clusters microservice's certificate endpoints. -/// -public static class CertificateProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/clusters/{clusterId:guid}/certificates") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/clusters/{clusterId}/certificates/cas — list all CAs - - group.MapGet("cas", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/certificates/cas", ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{clusterId}/certificates/cas/internal — create internal CA - - group.MapPost("cas/internal", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/internal", content, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{clusterId}/certificates/cas/domain — create domain CA - - group.MapPost("cas/domain", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/domain", content, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // DELETE /api/clusters/{clusterId}/certificates/cas/{name} — delete a CA - - group.MapDelete("cas/{name}", async (Guid clusterId, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{clusterId}/certificates/cas/{name}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{clusterId}/certificates/cas/{name}/rotate — rotate a CA - - group.MapPost("cas/{name}/rotate", async (Guid clusterId, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/{name}/rotate", null, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{clusterId}/certificates/cas/{name}/publish — publish CA to trust bundle - - group.MapPost("cas/{name}/publish", async (Guid clusterId, string name, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/{name}/publish", content, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/clusters/{clusterId}/certificates/list — list all TLS certificates - - group.MapGet("list", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/certificates/list", ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{clusterId}/certificates/publish — publish a leaf cert to trust bundle - - group.MapPost("publish", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/publish", content, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - } -} diff --git a/src/EntKube.Web/Components/Clusters/ClustersProxyEndpoints.cs b/src/EntKube.Web/Components/Clusters/ClustersProxyEndpoints.cs deleted file mode 100644 index 3a4e692..0000000 --- a/src/EntKube.Web/Components/Clusters/ClustersProxyEndpoints.cs +++ /dev/null @@ -1,381 +0,0 @@ -using EntKube.SharedKernel.Contracts; -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Clusters; - -/// -/// BFF proxy endpoints for cluster management. The Blazor frontend calls these -/// endpoints, and the BFF forwards the requests to the Clusters microservice. -/// This keeps the frontend from needing to know about backend service URLs -/// and allows the BFF to attach auth tokens, apply rate limiting, etc. -/// -public static class ClustersProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/clusters") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/clusters — list all clusters (optionally filtered by tenantId and/or environmentId) - group.MapGet("", async (Guid? tenantId, Guid? environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - - List queryParams = new(); - - if (tenantId.HasValue) - { - queryParams.Add($"tenantId={tenantId.Value}"); - } - - if (environmentId.HasValue) - { - queryParams.Add($"environmentId={environmentId.Value}"); - } - - string url = queryParams.Count > 0 - ? $"/api/clusters?{string.Join("&", queryParams)}" - : "/api/clusters"; - - HttpResponseMessage response = await client.GetAsync(url, ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/clusters/{id} — get a single cluster - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters — register a new cluster - group.MapPost("", async (RegisterClusterRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsJsonAsync("/api/clusters", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/clusters/{id} — remove a cluster - group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.NoContent(); - }); - - // POST /api/clusters/{id}/discover-prometheus — scan for Prometheus instances - group.MapPost("{id:guid}/discover-prometheus", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/discover-prometheus", null, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/clusters/{id}/prometheus — get discovered Prometheus endpoints - group.MapGet("{id:guid}/prometheus", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/prometheus", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{id}/install-prometheus — install kube-prometheus-stack - group.MapPost("{id:guid}/install-prometheus", async (Guid id, InstallPrometheusRequest? body, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/install-prometheus", body ?? new InstallPrometheusRequest(null), ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/clusters/{id}/health — get cluster health report from Prometheus - group.MapGet("{id:guid}/health", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/health", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/clusters/{id}/check-connectivity — probe API server, update status - group.MapPost("{id:guid}/check-connectivity", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/check-connectivity", null, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/adopt — run adoption checks, return component report - group.MapPost("{id:guid}/adopt", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/adopt", null, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/components/deploy — deploy a component - group.MapPost("{id:guid}/components/deploy", async (Guid id, DeployComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/deploy", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/components/upgrade — upgrade an installed component to a new version - group.MapPost("{id:guid}/components/upgrade", async (Guid id, UpgradeComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/upgrade", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/components/{name}/versions — get available versions for a component - group.MapGet("components/{name}/versions", async (string name, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/components/{name}/versions", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/components/uninstall — uninstall a component - group.MapPost("{id:guid}/components/uninstall", async (Guid id, UninstallComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/uninstall", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/clusters/{id}/components/{name}/configure — configure a component - group.MapPut("{id:guid}/components/{name}/configure", async (Guid id, string name, ConfigureComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/clusters/{id}/components/{name}/configure", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/components/schemas — get configuration schemas for all components - group.MapGet("components/schemas", async (IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync("/api/clusters/components/schemas", ct); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/clusters/{id}/components/schemas — get schemas enriched with cluster context - group.MapGet("{id:guid}/components/schemas", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/components/schemas", ct); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/clusters/{id}/services — list services for this cluster (from Provisioning) - group.MapGet("{id:guid}/services", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/services?clusterId={id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // PUT /api/clusters/{id}/provider — set or clear the cloud provider - group.MapPut("{id:guid}/provider", async (Guid id, SetProviderRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/clusters/{id}/provider", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/provider/test — test provider connection (with optional 2FA code) - group.MapPost("{id:guid}/provider/test", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - - // Forward the request body (may contain TwoFactorCode for SMS 2FA). - - StreamContent? content = null; - - if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType is not null) - { - content = new StreamContent(httpContext.Request.Body); - content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( - httpContext.Request.ContentType ?? "application/json"); - } - - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/provider/test", content, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/{id}/storage/info — S3 storage availability info - group.MapGet("{id:guid}/storage/info", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/info", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/{id}/storage/credentials — list S3 credentials - group.MapGet("{id:guid}/storage/credentials", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/credentials", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/storage/credentials — create new S3 credentials - group.MapPost("{id:guid}/storage/credentials", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/storage/credentials", new StringContent("", System.Text.Encoding.UTF8, "application/json"), ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/clusters/{id}/storage/credentials/{accessKey} — revoke S3 credentials - group.MapDelete("{id:guid}/storage/credentials/{accessKey}", async (Guid id, string accessKey, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}/storage/credentials/{accessKey}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/{id}/storage/buckets — list storage buckets - group.MapGet("{id:guid}/storage/buckets", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/buckets", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/clusters/{id}/storage/buckets — create a new bucket - group.MapPost("{id:guid}/storage/buckets", async (Guid id, HttpRequest httpRequest, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - string requestBody = await new StreamReader(httpRequest.Body).ReadToEndAsync(ct); - HttpResponseMessage response = await client.PostAsync( - $"/api/clusters/{id}/storage/buckets", - new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"), ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/{id}/storage/buckets/{bucketId} — get bucket details - group.MapGet("{id:guid}/storage/buckets/{bucketId:guid}", async (Guid id, Guid bucketId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/buckets/{bucketId}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/clusters/{id}/storage/buckets/{bucketId} — delete a bucket - group.MapDelete("{id:guid}/storage/buckets/{bucketId:guid}", async (Guid id, Guid bucketId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}/storage/buckets/{bucketId}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/clusters/{id}/settings — read cluster settings from live cluster - group.MapGet("{id:guid}/settings", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/settings", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/clusters/{id}/settings — update cluster settings on live cluster - group.MapPut("{id:guid}/settings", async (Guid id, HttpRequest httpRequest, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - string requestBody = await new StreamReader(httpRequest.Body).ReadToEndAsync(ct); - HttpResponseMessage response = await client.PutAsync( - $"/api/clusters/{id}/settings", - new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"), ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - } -} - -public record RegisterClusterRequest(string Name, string ApiServerUrl, string KubeConfig, Guid TenantId, string ContextName, Guid EnvironmentId); - -public record SetProviderRequest( - string Provider, - string? Username, - string? Password, - string? Region, - string? OpenStackAuthUrl = null, - string? OpenStackProjectId = null, - string? OpenStackUsername = null, - string? OpenStackPassword = null, - string? OpenStackUserDomainName = null); - -public record InstallPrometheusRequest(string? Namespace); - -public record DeployComponentRequest(string ComponentName, string? Version = null, string? Namespace = null, Dictionary? Parameters = null); - -public record UninstallComponentRequest(string ComponentName, string? Namespace = null, Dictionary? Parameters = null); - -public record UpgradeComponentRequest(string ComponentName, string Version); - -public record ConfigureComponentRequest(string? Namespace = null, Dictionary? Values = null); diff --git a/src/EntKube.Web/Components/Identity/TenantsProxyEndpoints.cs b/src/EntKube.Web/Components/Identity/TenantsProxyEndpoints.cs deleted file mode 100644 index d700010..0000000 --- a/src/EntKube.Web/Components/Identity/TenantsProxyEndpoints.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Identity; - -/// -/// BFF proxy endpoints for tenant management. The Blazor frontend calls these -/// endpoints, and the BFF forwards requests to the Identity microservice. -/// -/// Every handler returns JSON — even on failure — because Blazor WASM clients -/// deserialize responses with GetFromJsonAsync. If we let exceptions propagate -/// or return bare status codes, UseStatusCodePagesWithReExecute kicks in and -/// serves HTML, which causes "'<' is an invalid start of a value" errors. -/// -public static class TenantsProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/tenants") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/tenants — list all tenants - group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, "/api/tenants", ct); - }); - - // GET /api/tenants/{id} — get a single tenant - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{id}", ct); - }); - - // POST /api/tenants — create a new tenant - group.MapPost("", async (CreateTenantRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, "/api/tenants", request, ct); - }); - - // ─── Environment endpoints ─────────────────────────────────────── - - // GET /api/tenants/{tenantId}/environments — list environments for a tenant - group.MapGet("{tenantId:guid}/environments", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{tenantId}/environments", ct); - }); - - // POST /api/tenants/{tenantId}/environments — create an environment - group.MapPost("{tenantId:guid}/environments", async (Guid tenantId, CreateEnvironmentProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/tenants/{tenantId}/environments", request, ct); - }); - - // ─── Customer endpoints ────────────────────────────────────────── - - // GET /api/tenants/{tenantId}/customers — list customers for a tenant - group.MapGet("{tenantId:guid}/customers", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{tenantId}/customers", ct); - }); - - // POST /api/tenants/{tenantId}/customers — create a customer - group.MapPost("{tenantId:guid}/customers", async (Guid tenantId, CreateCustomerProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/tenants/{tenantId}/customers", request, ct); - }); - } - - // ─── Shared proxy helpers ───────────────────────────────────────────── - // - // These catch HttpRequestException (backend unreachable) and non-success - // responses, always returning a JSON ApiError so Blazor clients never - // receive HTML error pages. - - private static async Task ProxyGetAsync( - IHttpClientFactory httpClientFactory, string path, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("IdentityApi"); - HttpResponseMessage response = await client.GetAsync(path, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Identity service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Identity service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private static async Task ProxyPostAsync( - IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("IdentityApi"); - HttpResponseMessage response = await client.PostAsJsonAsync(path, payload, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Identity service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Identity service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private record ApiError(bool Success, string Error); -} - -public record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId); -public record CreateEnvironmentProxyRequest(string Name, string Slug); -public record CreateCustomerProxyRequest(string Name, string Slug); diff --git a/src/EntKube.Web.Client/Layout/MainLayout.razor b/src/EntKube.Web/Components/Layout/MainLayout.razor similarity index 100% rename from src/EntKube.Web.Client/Layout/MainLayout.razor rename to src/EntKube.Web/Components/Layout/MainLayout.razor diff --git a/src/EntKube.Web.Client/Layout/MainLayout.razor.css b/src/EntKube.Web/Components/Layout/MainLayout.razor.css similarity index 100% rename from src/EntKube.Web.Client/Layout/MainLayout.razor.css rename to src/EntKube.Web/Components/Layout/MainLayout.razor.css diff --git a/src/EntKube.Web.Client/Layout/NavMenu.razor b/src/EntKube.Web/Components/Layout/NavMenu.razor similarity index 81% rename from src/EntKube.Web.Client/Layout/NavMenu.razor rename to src/EntKube.Web/Components/Layout/NavMenu.razor index 37421a9..b9fb14e 100644 --- a/src/EntKube.Web.Client/Layout/NavMenu.razor +++ b/src/EntKube.Web/Components/Layout/NavMenu.razor @@ -4,7 +4,7 @@ @@ -18,27 +18,15 @@ - - - - diff --git a/src/EntKube.Web.Client/Layout/NavMenu.razor.css b/src/EntKube.Web/Components/Layout/NavMenu.razor.css similarity index 100% rename from src/EntKube.Web.Client/Layout/NavMenu.razor.css rename to src/EntKube.Web/Components/Layout/NavMenu.razor.css diff --git a/src/EntKube.Web.Client/Layout/ReconnectModal.razor b/src/EntKube.Web/Components/Layout/ReconnectModal.razor similarity index 92% rename from src/EntKube.Web.Client/Layout/ReconnectModal.razor rename to src/EntKube.Web/Components/Layout/ReconnectModal.razor index 50d55c3..0bf65fc 100644 --- a/src/EntKube.Web.Client/Layout/ReconnectModal.razor +++ b/src/EntKube.Web/Components/Layout/ReconnectModal.razor @@ -1,4 +1,4 @@ - +
diff --git a/src/EntKube.Web.Client/Layout/ReconnectModal.razor.css b/src/EntKube.Web/Components/Layout/ReconnectModal.razor.css similarity index 100% rename from src/EntKube.Web.Client/Layout/ReconnectModal.razor.css rename to src/EntKube.Web/Components/Layout/ReconnectModal.razor.css diff --git a/src/EntKube.Web.Client/Layout/ReconnectModal.razor.js b/src/EntKube.Web/Components/Layout/ReconnectModal.razor.js similarity index 100% rename from src/EntKube.Web.Client/Layout/ReconnectModal.razor.js rename to src/EntKube.Web/Components/Layout/ReconnectModal.razor.js diff --git a/src/EntKube.Web/Components/Pages/Home.razor b/src/EntKube.Web/Components/Pages/Home.razor new file mode 100644 index 0000000..102df12 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Home.razor @@ -0,0 +1,10 @@ +@page "/" + +EntKube + +

EntKube

+

Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.

+ + diff --git a/src/EntKube.Web.Client/Pages/NotFound.razor b/src/EntKube.Web/Components/Pages/NotFound.razor similarity index 100% rename from src/EntKube.Web.Client/Pages/NotFound.razor rename to src/EntKube.Web/Components/Pages/NotFound.razor diff --git a/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor b/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor new file mode 100644 index 0000000..edb6aa9 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor @@ -0,0 +1,293 @@ +@page "/portal" +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [Authorize] +@rendermode InteractiveServer +@inject CustomerAccessService CustomerAccessService +@inject DeploymentService DeploymentService +@inject KubernetesOperationsService K8sOps +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation + +Customer Portal + +@* ═══════════════════════════════════════════════════════════════════ + Customer Portal — a scoped view where a user sees only the customers + they've been granted access to. From here they can drill into apps, + deployments, view pod logs, restart deployments, and delete pods. + + Navigation flow: My Customers → Apps → Deployments → Operations + ═══════════════════════════════════════════════════════════════════ *@ + +@if (loading) +{ +
+
+

Loading portal...

+
+} +else if (customers is null || customers.Count == 0) +{ +
+ +

No access granted

+

You don't have access to any customers yet. Ask a tenant administrator to grant you access.

+
+} +else +{ + @* ── Level 0: Breadcrumb navigation ── *@ + + + @* ════════════════════════════════════════════════════════════════ + Level 1: Customer list + ════════════════════════════════════════════════════════════════ *@ + @if (selectedCustomer is null) + { +
+ +

My Customers

+
+ +
+ @foreach (Customer customer in customers) + { +
+
+
+
+ +
@customer.Name
+
+
+ + @customer.Apps.Count app@(customer.Apps.Count != 1 ? "s" : "") +
+
+
+
+ } +
+ } + + @* ════════════════════════════════════════════════════════════════ + Level 2: Apps and deployments for a selected customer + ════════════════════════════════════════════════════════════════ *@ + else if (selectedDeployment is null) + { +
+ +
+

@selectedCustomer.Name

+ @selectedCustomer.Apps.Count app@(selectedCustomer.Apps.Count != 1 ? "s" : "") +
+
+ + @if (selectedCustomer.Apps.Count == 0) + { +
+ +

No apps configured for this customer yet.

+
+ } + else + { + @foreach (Data.App app in selectedCustomer.Apps.OrderBy(a => a.Name)) + { +
+
+
+ + @app.Name +
+
+
+ @if (appDeployments.TryGetValue(app.Id, out List? deploys) && deploys.Count > 0) + { +
+ @foreach (AppDeployment deploy in deploys) + { +
+
+
+
+ + @deploy.Name + +
+ @SyncBadge(deploy.SyncStatus) + @HealthBadge(deploy.HealthStatus) +
+
+
+ @TypeBadge(deploy.Type) + @deploy.Environment?.Name + @deploy.Cluster?.Name + @deploy.Namespace +
+
+
+
+ } +
+ } + else + { +

No deployments configured.

+ } +
+
+ } + } + } + + @* ════════════════════════════════════════════════════════════════ + Level 3: Deployment detail with operations + ════════════════════════════════════════════════════════════════ *@ + else + { + + } +} + +@code { + private bool loading = true; + private string? currentUserId; + private CustomerAccessRole currentAccessRole; + + private List? customers; + private Customer? selectedCustomer; + private AppDeployment? selectedDeployment; + + // Deployments indexed by app ID for the selected customer. + private Dictionary> appDeployments = new(); + + protected override async Task OnInitializedAsync() + { + // Determine who the logged-in user is. + AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync(); + currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(currentUserId)) + { + loading = false; + return; + } + + // Load only the customers this user has access to. + customers = await CustomerAccessService.GetAccessibleCustomersAsync(currentUserId); + loading = false; + } + + private async Task SelectCustomer(Customer customer) + { + selectedCustomer = customer; + + // Look up the user's role for this customer. + CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id); + currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer; + + // Load deployments for all of the customer's apps. + appDeployments.Clear(); + + foreach (Data.App app in customer.Apps) + { + List deploys = await DeploymentService.GetDeploymentsAsync(app.Id); + appDeployments[app.Id] = deploys; + } + } + + private void SelectDeployment(AppDeployment deploy) + { + selectedDeployment = deploy; + } + + private void BackToCustomers() + { + selectedCustomer = null; + selectedDeployment = null; + } + + private void BackToApps() + { + selectedDeployment = null; + } + + // ──────── Badge helpers ──────── + + private RenderFragment TypeBadge(DeploymentType type) => type switch + { + DeploymentType.Manual => @Manual, + DeploymentType.Yaml => @YAML, + DeploymentType.HelmChart => @Helm, + _ => @Unknown + }; + + private RenderFragment SyncBadge(SyncStatus status) => status switch + { + SyncStatus.Synced => @Synced, + SyncStatus.OutOfSync => @OutOfSync, + SyncStatus.Syncing => @Syncing, + SyncStatus.Failed => @Failed, + _ => @Unknown + }; + + private RenderFragment HealthBadge(HealthStatus status) => status switch + { + HealthStatus.Healthy => @Healthy, + HealthStatus.Progressing => @Progressing, + HealthStatus.Degraded => @Degraded, + HealthStatus.Missing => @Missing, + HealthStatus.Suspended => @Suspended, + _ => @Unknown + }; +} diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor new file mode 100644 index 0000000..723927c --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor @@ -0,0 +1,592 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services + +@* ═══════════════════════════════════════════════════════════════════ + Deployment operations view — the heart of the customer portal. + Shows deployment info, live pods, pod logs, and allows restart/ + delete/scale operations. Access is role-gated: Viewers can only + browse and read logs; Operators can restart and delete pods; + Admins have full control. + ═══════════════════════════════════════════════════════════════════ *@ + +@* --- Deployment header --- *@ +
+
+
+
+
+ @Deployment.Name +
+
+ @TypeBadge(Deployment.Type) + @Deployment.Environment?.Name + @Deployment.Cluster?.Name + @Deployment.Namespace +
+
+
+ @SyncBadge(Deployment.SyncStatus) + @HealthBadge(Deployment.HealthStatus) +
+
+ + @if (Deployment.Type == DeploymentType.HelmChart) + { +
+ + @Deployment.HelmChartName @Deployment.HelmChartVersion + from @Deployment.HelmRepoUrl +
+ } + + @if (!string.IsNullOrEmpty(Deployment.StatusMessage)) + { +
@Deployment.StatusMessage
+ } + + @if (Deployment.LastSyncedAt.HasValue) + { +
+ Last synced @Deployment.LastSyncedAt.Value.ToString("g") +
+ } +
+
+ +@* --- Tabs: Pods / Manifests / Resources --- *@ + + +@* ══════════════════════════════════════════════════════════════ + Pods Tab — live pod list from the cluster + ══════════════════════════════════════════════════════════════ *@ +@if (activeTab == "pods") +{ + @if (!string.IsNullOrEmpty(operationMessage)) + { +
+ @operationMessage + +
+ } + + @* --- Operation buttons (role-gated) --- *@ + @if (AccessRole >= CustomerAccessRole.Operator) + { +
+ +
+ } + + @if (podsLoading) + { +
+
+

Loading pods from cluster...

+
+ } + else if (!string.IsNullOrEmpty(podsError)) + { +
+ @podsError +
+ } + else if (pods is null || pods.Count == 0) + { +
+ +

No pods found in namespace @Deployment.Namespace.

+
+ } + else + { + @* --- Pod log viewer (shown above pods when active) --- *@ + @if (showLogViewer) + { +
+
+
+ + Logs: @logPodName + @if (!string.IsNullOrEmpty(logContainerName)) + { + (@logContainerName) + } +
+ +
+
+ @if (logLoading) + { +
+
+
+ } + else if (!string.IsNullOrEmpty(logError)) + { +
@logError
+ } + else + { +
@logContent
+ } +
+
+ } + + @* --- Pod cards --- *@ + @foreach (PodInfo pod in pods) + { +
+
+
+
+ + @pod.Name + + @pod.Status + @pod.ReadyContainers/@pod.TotalContainers ready + @if (pod.Restarts > 0) + { + @pod.Restarts restarts + } +
+
+ @* --- Logs button (always available) --- *@ + @if (pod.Containers.Count <= 1) + { + + } + else + { + @* Multi-container: dropdown to pick container *@ +
+ + +
+ } + + @* --- Delete pod (Operator+ only) --- *@ + @if (AccessRole >= CustomerAccessRole.Operator) + { + @if (confirmDeletePod == pod.Name) + { + + + } + else + { + + } + } +
+
+ + @* --- Container details (expanded) --- *@ + @if (pod.Containers.Count > 1) + { +
+ @foreach (ContainerInfo container in pod.Containers) + { +
+ + @container.Name + @container.Image + + @container.State + + @if (container.RestartCount > 0) + { + @container.RestartCount restarts + } +
+ } +
+ } + + @if (pod.StartTime.HasValue) + { +
+ Started @pod.StartTime.Value.ToString("g") +
+ } +
+
+ } + } +} + +@* ══════════════════════════════════════════════════════════════ + Manifests Tab + ══════════════════════════════════════════════════════════════ *@ +@if (activeTab == "manifests") +{ + @if (manifests is null) + { +
+
+
+ } + else if (manifests.Count == 0) + { +
+ +

No manifests defined for this deployment.

+
+ } + else + { + @foreach (DeploymentManifest manifest in manifests) + { +
+
+ @manifest.Kind + @manifest.Name +
+
+
@manifest.YamlContent
+
+
+ } + } +} + +@* ══════════════════════════════════════════════════════════════ + Resources Tab (ArgoCD-style tree) + ══════════════════════════════════════════════════════════════ *@ +@if (activeTab == "resources") +{ + @if (resourceTree is null) + { +
+
+
+ } + else if (resourceTree.Count == 0) + { +
+ +

No tracked resources.

+
+ } + else + { + @foreach (DeploymentResource root in resourceTree) + { + @RenderResource(root, 0) + } + } +} + +@code { + [Parameter] public required AppDeployment Deployment { get; set; } + [Parameter] public CustomerAccessRole AccessRole { get; set; } + [Parameter] public required KubernetesOperationsService K8sOps { get; set; } + [Parameter] public required DeploymentService DeploymentService { get; set; } + [Parameter] public EventCallback OnBack { get; set; } + + private string activeTab = "pods"; + + // Pods + private List? pods; + private bool podsLoading; + private string? podsError; + + // Operations + private bool operationInProgress; + private string? operationMessage; + private bool operationSuccess; + private string? confirmDeletePod; + + // Logs + private bool showLogViewer; + private bool logLoading; + private string logPodName = ""; + private string? logContainerName; + private string logContent = ""; + private string? logError; + + // Manifests + private List? manifests; + + // Resources + private List? resourceTree; + + protected override async Task OnInitializedAsync() + { + await LoadPodsInternal(); + } + + // ──────── Pod operations ──────── + + private async Task LoadPods() + { + activeTab = "pods"; + await LoadPodsInternal(); + } + + private async Task LoadPodsInternal() + { + podsLoading = true; + podsError = null; + pods = null; + StateHasChanged(); + + KubernetesOperationResult> result = await K8sOps.GetPodsAsync(Deployment.Id); + + if (result.IsSuccess) + { + pods = result.Data ?? []; + } + else + { + podsError = result.Error; + } + + podsLoading = false; + } + + private async Task ViewLogs(string podName, string? containerName) + { + showLogViewer = true; + logLoading = true; + logPodName = podName; + logContainerName = containerName; + logContent = ""; + logError = null; + StateHasChanged(); + + KubernetesOperationResult result = await K8sOps.GetPodLogsAsync( + Deployment.Id, podName, containerName); + + if (result.IsSuccess) + { + logContent = result.Data ?? "(empty)"; + } + else + { + logError = result.Error; + } + + logLoading = false; + } + + private void CloseLogViewer() + { + showLogViewer = false; + } + + private async Task RestartAllDeployments() + { + operationInProgress = true; + operationMessage = null; + StateHasChanged(); + + // Find all Deployment-kind resources tracked for this deployment + // and restart each one. If none are tracked, try the deployment name. + List tree = await DeploymentService.GetResourceTreeAsync(Deployment.Id); + List deploymentNames = tree + .Where(r => r.Kind == "Deployment") + .Select(r => r.Name) + .ToList(); + + if (deploymentNames.Count == 0) + { + // Fallback: use the deployment name itself. + deploymentNames.Add(Deployment.Name); + } + + List failures = []; + + foreach (string name in deploymentNames) + { + KubernetesOperationResult result = await K8sOps.RestartDeploymentAsync(Deployment.Id, name); + + if (!result.IsSuccess) + { + failures.Add($"{name}: {result.Error}"); + } + } + + if (failures.Count == 0) + { + operationMessage = $"Rolling restart triggered for {deploymentNames.Count} deployment(s)."; + operationSuccess = true; + } + else + { + operationMessage = $"Some restarts failed: {string.Join("; ", failures)}"; + operationSuccess = false; + } + + operationInProgress = false; + + // Refresh pods after a short delay to show new status. + await Task.Delay(2000); + await LoadPodsInternal(); + } + + private async Task DeletePod(string podName) + { + operationInProgress = true; + confirmDeletePod = null; + StateHasChanged(); + + KubernetesOperationResult result = await K8sOps.DeletePodAsync(Deployment.Id, podName); + + if (result.IsSuccess) + { + operationMessage = $"Pod '{podName}' deleted. Kubernetes will create a replacement."; + operationSuccess = true; + } + else + { + operationMessage = result.Error; + operationSuccess = false; + } + + operationInProgress = false; + + // Refresh pods. + await Task.Delay(2000); + await LoadPodsInternal(); + } + + // ──────── Manifests ──────── + + private async Task LoadManifests() + { + activeTab = "manifests"; + + if (manifests is null) + { + manifests = await DeploymentService.GetManifestsAsync(Deployment.Id); + } + } + + // ──────── Resources ──────── + + private async Task LoadResources() + { + activeTab = "resources"; + + if (resourceTree is null) + { + resourceTree = await DeploymentService.GetResourceTreeAsync(Deployment.Id); + } + } + + // ──────── Rendering helpers ──────── + + private static string GetPodStatusColor(string status) => status switch + { + "Running" => "text-success", + "Pending" => "text-warning", + "Succeeded" => "text-info", + "Failed" => "text-danger", + _ => "text-muted" + }; + + private static string GetPodStatusBadgeClass(string status) => status switch + { + "Running" => "bg-success", + "Pending" => "bg-warning text-dark", + "Succeeded" => "bg-info", + "Failed" => "bg-danger", + _ => "bg-secondary" + }; + + private RenderFragment TypeBadge(DeploymentType type) => type switch + { + DeploymentType.Manual => @Manual, + DeploymentType.Yaml => @YAML, + DeploymentType.HelmChart => @Helm, + _ => @Unknown + }; + + private RenderFragment SyncBadge(SyncStatus status) => status switch + { + SyncStatus.Synced => @Synced, + SyncStatus.OutOfSync => @OutOfSync, + SyncStatus.Syncing => @Syncing, + SyncStatus.Failed => @Failed, + _ => @Unknown + }; + + private RenderFragment HealthBadge(HealthStatus status) => status switch + { + HealthStatus.Healthy => @Healthy, + HealthStatus.Progressing => @Progressing, + HealthStatus.Degraded => @Degraded, + HealthStatus.Missing => @Missing, + HealthStatus.Suspended => @Suspended, + _ => @Unknown + }; + + private RenderFragment RenderResource(DeploymentResource resource, int depth) => __builder => + { +
+ @resource.Kind + @resource.Name +
+ @SyncBadge(resource.SyncStatus) + @HealthBadge(resource.HealthStatus) +
+
+ @if (resource.ChildResources?.Count > 0) + { + @foreach (DeploymentResource child in resource.ChildResources) + { + @RenderResource(child, depth + 1) + } + } + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor new file mode 100644 index 0000000..95aa7af --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor @@ -0,0 +1,325 @@ +@using EntKube.Web.Services + +@* ═══════════════════════════════════════════════════════════════════════════ + AlertPanel — shows active alerts from Alertmanager, allows creating silences, + and displays existing silences. The central tool for operations teams to + monitor and manage alert noise. + ═══════════════════════════════════════════════════════════════════════════ *@ + +
+
+ + Alerts + @if (alerts is not null && alerts.Count > 0) + { + @alerts.Count + } + +
+ + + +
+
+
+ @if (loading) + { +
+
+
+ } + else if (!string.IsNullOrEmpty(errorMessage)) + { +
+ @errorMessage +
+ } + else if (view == "alerts") + { + @* ── Active Alerts ── *@ + @if (alerts is null || alerts.Count == 0) + { +
+ +

No active alerts. All clear!

+
+ } + else + { +
+ @foreach (AlertInfo alert in alerts.OrderByDescending(a => a.Severity == "critical") + .ThenByDescending(a => a.Severity == "warning")) + { +
+
+
+
+ @alert.Severity + @alert.Name + @alert.State +
+ @if (!string.IsNullOrEmpty(alert.Summary)) + { +

@alert.Summary

+ } +
+ @foreach (KeyValuePair label in alert.Labels + .Where(l => l.Key != "alertname" && l.Key != "severity")) + { + + @label.Key=@label.Value + + } +
+ + Started @FormatTimeAgo(alert.StartsAt) + +
+ +
+
+ } +
+ } + } + else if (view == "silences") + { + @* ── Silences ── *@ + @if (silences is null || silences.Count == 0) + { +
+ +

No silences configured.

+
+ } + else + { +
+ @foreach (SilenceInfo silence in silences.Where(s => s.State == "active")) + { +
+
+
+
+ active + @silence.Comment +
+
+ @foreach (SilenceMatcher matcher in silence.Matchers) + { + + @matcher.Name @(matcher.IsEqual ? "=" : "!=") @(matcher.IsRegex ? "~" : "")@matcher.Value + + } +
+ + By @silence.CreatedBy · Expires @silence.EndsAt.ToString("g") + +
+
+
+ } +
+ } + } + + @* ── Create Silence Form ── *@ + @if (showCreateSilence) + { +
+
Create Silence
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(silenceError)) + { +
@silenceError
+ } +
+ } +
+
+ +@code { + [Parameter] public Guid ClusterId { get; set; } + [Parameter] public PrometheusService PrometheusService { get; set; } = null!; + + private List? alerts; + private List? silences; + private string view = "alerts"; + private bool loading; + private string? errorMessage; + + // Silence form + private bool showCreateSilence; + private string silenceMatcherInput = ""; + private int silenceDurationHours = 2; + private string silenceComment = ""; + private string? silenceError; + + protected override async Task OnInitializedAsync() + { + await RefreshAlerts(); + } + + private async Task RefreshAlerts() + { + loading = true; + errorMessage = null; + StateHasChanged(); + + KubernetesOperationResult> result = + await PrometheusService.GetAlertsAsync(ClusterId); + + if (result.IsSuccess) + { + alerts = result.Data; + } + else + { + errorMessage = result.Error; + } + + loading = false; + } + + private async Task LoadSilences() + { + view = "silences"; + loading = true; + errorMessage = null; + StateHasChanged(); + + KubernetesOperationResult> result = + await PrometheusService.GetSilencesAsync(ClusterId); + + if (result.IsSuccess) + { + silences = result.Data; + } + else + { + errorMessage = result.Error; + } + + loading = false; + } + + private void StartSilence(AlertInfo alert) + { + showCreateSilence = true; + silenceMatcherInput = $"alertname={alert.Name}"; + silenceComment = ""; + silenceError = null; + } + + private void CancelSilence() + { + showCreateSilence = false; + silenceMatcherInput = ""; + silenceComment = ""; + silenceError = null; + } + + private async Task SubmitSilence() + { + silenceError = null; + + // Parse matcher input "label=value" into a SilenceMatcher. + + string[] parts = silenceMatcherInput.Split('=', 2); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0])) + { + silenceError = "Matcher format: label=value"; + return; + } + + List matchers = + [ + new SilenceMatcher { Name = parts[0].Trim(), Value = parts[1].Trim(), IsEqual = true } + ]; + + KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync( + ClusterId, + silenceComment, + "entkube-user", + TimeSpan.FromHours(silenceDurationHours), + matchers); + + if (result.IsSuccess) + { + showCreateSilence = false; + silenceMatcherInput = ""; + silenceComment = ""; + await LoadSilences(); + } + else + { + silenceError = result.Error; + } + } + + private static string GetSeverityBadgeClass(string severity) => severity switch + { + "critical" => "bg-danger", + "warning" => "bg-warning text-dark", + "info" => "bg-info text-dark", + _ => "bg-secondary" + }; + + private static string FormatTimeAgo(DateTime time) + { + if (time == DateTime.MinValue) + { + return "unknown"; + } + + TimeSpan ago = DateTime.UtcNow - time; + return ago switch + { + { TotalMinutes: < 1 } => "just now", + { TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m ago", + { TotalHours: < 24 } => $"{(int)ago.TotalHours}h ago", + _ => $"{(int)ago.TotalDays}d ago" + }; + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor new file mode 100644 index 0000000..2700cee --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor @@ -0,0 +1,840 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService +@inject DeploymentService DeploymentService + +@* ═══════════════════════════════════════════════════════════════════ + App Detail — a full-page view for a single application. + Shows app info, environment links, and is the extension point + for future app-level features (config, deployments, secrets, etc.). + ═══════════════════════════════════════════════════════════════════ *@ + +@* --- Back button --- *@ + + +@* --- App header --- *@ +
+
+
+
+ +
+

@App.Name

+
+ @CustomerName + Created @App.CreatedAt.ToString("MMM d, yyyy") +
+
+
+ + @if (!confirmDelete) + { + + } +
+ + @if (confirmDelete) + { +
+ + + Permanently delete @App.Name and all its data? + +
+ + +
+
+ } +
+
+ +@* --- App sub-sections (tabs for future expansion) --- *@ + + +@if (section == "environments") +{ +
+
+
+ + Environment Links +
+ Select which environments this app is deployed to. +
+
+ @if (environments is null) + { +
+
+
+ } + else if (environments.Count == 0) + { +
+ +

No environments exist for this tenant yet. Create environments first.

+
+ } + else + { +
+ @foreach (Data.Environment env in environments) + { + bool isLinked = linkedEnvIds.Contains(env.Id); + +
+
+
+ +
+ @env.Name +
+ @if (isLinked) + { + Active + } +
+
+
+ } +
+ } +
+
+} + +@* ═══════════════════════════════════════════════════════════════════ + Deployments Tab — manage deployments (Manual, YAML, Helm) targeting + specific clusters. Shows ArgoCD-style sync/health status. + ═══════════════════════════════════════════════════════════════════ *@ +@if (section == "deployments") +{ + @* --- Deployment detail view (drill-down into a single deployment) --- *@ + @if (selectedDeployment is not null) + { + + +
+
+
+
+
+ @selectedDeployment.Name +
+
+ @GetTypeBadge(selectedDeployment.Type) + @selectedDeployment.Environment?.Name + @selectedDeployment.Cluster?.Name + @selectedDeployment.Namespace +
+
+
+ @GetSyncBadge(selectedDeployment.SyncStatus) + @GetHealthBadge(selectedDeployment.HealthStatus) + + @if (!confirmDeleteDeployment) + { + + } +
+
+ + @if (confirmDeleteDeployment) + { +
+ + + Permanently delete @selectedDeployment.Name and all its manifests and resources? + +
+ + +
+
+ } + + @if (selectedDeployment.Type == DeploymentType.HelmChart) + { +
+ + + @selectedDeployment.HelmChartName + @selectedDeployment.HelmChartVersion + from @selectedDeployment.HelmRepoUrl + +
+ } +
+
+ + @* --- Sub-tabs: Manifests / Resources / Helm Values --- *@ + + + @* ── Manifests sub-tab ── *@ + @if (deploymentSection == "manifests") + { +
+
+
+ + Manifests +
+ +
+
+ @if (showAddManifest) + { +
+
+
Add YAML Manifest
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ } + + @if (manifests is null) + { +
+
+
+ } + else if (manifests.Count == 0) + { +
+ +

No manifests yet. Add YAML manifests to define what gets deployed.

+
+ } + else + { + @foreach (DeploymentManifest manifest in manifests) + { +
+
+
+ @manifest.Kind + @manifest.Name + #@manifest.SortOrder +
+
+ @if (editingManifestId == manifest.Id) + { + + + } + else + { + + + } +
+
+ + @if (editingManifestId == manifest.Id) + { +
+ +
+ } + else + { +
+
@manifest.YamlContent
+
+ } +
+ } + } +
+
+ } + + @* ── Helm Values sub-tab ── *@ + @if (deploymentSection == "helm-values") + { +
+
+ + Helm Values +
+
+

Override the chart's default values (YAML format).

+ + +
+
+ } + + @* ── Resources sub-tab (ArgoCD-style tree) ── *@ + @if (deploymentSection == "resources") + { +
+
+ + Resource Tree +
+
+ @if (resourceTree is null) + { +
+
+
+ } + else if (resourceTree.Count == 0) + { +
+ +

No tracked resources yet. Resources appear here after the deployment syncs to the cluster.

+
+ } + else + { + @foreach (DeploymentResource root in resourceTree) + { +
+ @RenderResource(root, 0) +
+ } + } +
+
+ } + } + else + { + @* --- Deployment list view --- *@ +
+
+
+ + Deployments +
+ +
+
+ @* --- Create deployment form --- *@ + @if (showCreateDeployment) + { +
+
+
Create Deployment
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (newDeployType == DeploymentType.HelmChart) + { +
+
+ + +
+
+ + +
+
+ + +
+
+ } + + @if (!string.IsNullOrEmpty(createDeployError)) + { +
@createDeployError
+ } + +
+ + +
+
+
+ } + + @* --- Deployment list --- *@ + @if (deployments is null) + { +
+
+
+ } + else if (deployments.Count == 0) + { +
+ +

No deployments yet. Create one to start deploying to Kubernetes.

+
+ } + else + { +
+ @foreach (AppDeployment deploy in deployments) + { +
+
+
+
+ + @deploy.Name + +
+ @GetSyncBadge(deploy.SyncStatus) + @GetHealthBadge(deploy.HealthStatus) +
+
+
+ @GetTypeBadge(deploy.Type) + @deploy.Environment?.Name + @deploy.Cluster?.Name + @deploy.Namespace +
+ + @if (!string.IsNullOrEmpty(deploy.StatusMessage)) + { +
@deploy.StatusMessage
+ } +
+
+
+ } +
+ } +
+
+ } +} + +@code { + [Parameter] public required Data.App App { get; set; } + [Parameter] public Guid TenantId { get; set; } + [Parameter] public string CustomerName { get; set; } = ""; + [Parameter] public EventCallback OnBack { get; set; } + [Parameter] public EventCallback OnDeleted { get; set; } + + private string section = "environments"; + private bool confirmDelete; + + // Environments + private List? environments; + private HashSet linkedEnvIds = new(); + + // Deployments + private List? deployments; + private List? clusters; + private AppDeployment? selectedDeployment; + private string deploymentSection = "manifests"; + private bool confirmDeleteDeployment; + + // Create deployment form + private bool showCreateDeployment; + private string newDeployName = ""; + private DeploymentType newDeployType = DeploymentType.Manual; + private Guid newDeployEnvId; + private Guid newDeployClusterId; + private string newDeployNamespace = ""; + private string newHelmRepoUrl = ""; + private string newHelmChartName = ""; + private string newHelmChartVersion = ""; + private string? createDeployError; + + // Manifest management + private List? manifests; + private bool showAddManifest; + private string newManifestKind = ""; + private string newManifestName = ""; + private string newManifestYaml = ""; + private int newManifestSortOrder; + private Guid? editingManifestId; + private string editManifestYaml = ""; + + // Helm values + private string helmValuesYaml = ""; + + // Resource tree + private List? resourceTree; + + protected override async Task OnInitializedAsync() + { + await LoadEnvironments(); + } + + // ──────── App ──────── + + private async Task DeleteApp() + { + await TenantService.DeleteAppAsync(App.Id); + await OnDeleted.InvokeAsync(); + } + + // ──────── Environments ──────── + + private async Task LoadEnvironments() + { + environments = await TenantService.GetEnvironmentsAsync(TenantId); + + // Refresh the app to get current environment links. + List apps = await TenantService.GetAppsAsync(App.CustomerId); + Data.App? freshApp = apps.FirstOrDefault(a => a.Id == App.Id); + + linkedEnvIds = freshApp?.AppEnvironments.Select(ae => ae.EnvironmentId).ToHashSet() ?? new(); + } + + private async Task ToggleEnvironment(Guid envId, bool currentlyLinked) + { + if (currentlyLinked) + { + await TenantService.UnlinkAppFromEnvironmentAsync(App.Id, envId); + } + else + { + await TenantService.LinkAppToEnvironmentAsync(App.Id, envId); + } + + await LoadEnvironments(); + } + + // ──────── Deployments ──────── + + private async Task SwitchToDeployments() + { + section = "deployments"; + + if (deployments is null) + { + await LoadDeployments(); + } + + if (clusters is null) + { + clusters = await TenantService.GetClustersAsync(TenantId); + } + } + + private async Task LoadDeployments() + { + deployments = await DeploymentService.GetDeploymentsAsync(App.Id); + } + + private async Task CreateDeployment() + { + createDeployError = null; + + try + { + await DeploymentService.CreateDeploymentAsync( + App.Id, newDeployName, newDeployType, + newDeployEnvId, newDeployClusterId, newDeployNamespace, + newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null, + newDeployType == DeploymentType.HelmChart ? newHelmChartName : null, + newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null); + + // Reset form and reload. + showCreateDeployment = false; + newDeployName = ""; + newDeployNamespace = ""; + newHelmRepoUrl = ""; + newHelmChartName = ""; + newHelmChartVersion = ""; + await LoadDeployments(); + } + catch (DbUpdateException) + { + createDeployError = $"A deployment named '{newDeployName}' already exists for this app."; + } + } + + private async Task SelectDeployment(AppDeployment deploy) + { + selectedDeployment = deploy; + confirmDeleteDeployment = false; + + // Default to the appropriate sub-tab based on type. + if (deploy.Type == DeploymentType.HelmChart) + { + deploymentSection = "helm-values"; + helmValuesYaml = deploy.HelmValues ?? ""; + } + else + { + deploymentSection = "manifests"; + await LoadManifests(); + } + } + + private async Task DeleteDeployment() + { + if (selectedDeployment is not null) + { + await DeploymentService.DeleteDeploymentAsync(selectedDeployment.Id); + selectedDeployment = null; + confirmDeleteDeployment = false; + await LoadDeployments(); + } + } + + // ──────── Manifests ──────── + + private async Task LoadManifests() + { + if (selectedDeployment is not null) + { + manifests = await DeploymentService.GetManifestsAsync(selectedDeployment.Id); + } + } + + private async Task AddManifest() + { + if (selectedDeployment is null) + { + return; + } + + await DeploymentService.AddManifestAsync( + selectedDeployment.Id, newManifestKind, newManifestName, newManifestYaml, newManifestSortOrder); + + showAddManifest = false; + newManifestKind = ""; + newManifestName = ""; + newManifestYaml = ""; + newManifestSortOrder = 0; + await LoadManifests(); + } + + private void StartEditManifest(DeploymentManifest manifest) + { + editingManifestId = manifest.Id; + editManifestYaml = manifest.YamlContent; + } + + private async Task SaveManifest(Guid manifestId) + { + await DeploymentService.UpdateManifestAsync(manifestId, editManifestYaml); + editingManifestId = null; + await LoadManifests(); + } + + private async Task DeleteManifest(Guid manifestId) + { + await DeploymentService.DeleteManifestAsync(manifestId); + await LoadManifests(); + } + + // ──────── Helm Values ──────── + + private async Task SaveHelmValues() + { + if (selectedDeployment is not null) + { + await DeploymentService.UpdateHelmValuesAsync(selectedDeployment.Id, helmValuesYaml); + } + } + + // ──────── Resources ──────── + + private async Task OpenResourcesTab() + { + deploymentSection = "resources"; + + if (selectedDeployment is not null) + { + resourceTree = await DeploymentService.GetResourceTreeAsync(selectedDeployment.Id); + } + } + + // ──────── Rendering helpers ──────── + + private RenderFragment GetTypeBadge(DeploymentType type) => type switch + { + DeploymentType.Manual => @Manual, + DeploymentType.Yaml => @YAML, + DeploymentType.HelmChart => @Helm, + _ => @Unknown + }; + + private RenderFragment GetSyncBadge(SyncStatus status) => status switch + { + SyncStatus.Synced => @Synced, + SyncStatus.OutOfSync => @OutOfSync, + SyncStatus.Syncing => @Syncing, + SyncStatus.Failed => @Failed, + _ => @Unknown + }; + + private RenderFragment GetHealthBadge(HealthStatus status) => status switch + { + HealthStatus.Healthy => @Healthy, + HealthStatus.Progressing => @Progressing, + HealthStatus.Degraded => @Degraded, + HealthStatus.Missing => @Missing, + HealthStatus.Suspended => @Suspended, + _ => @Unknown + }; + + private RenderFragment RenderResource(DeploymentResource resource, int depth) => __builder => + { +
+ @resource.Kind + @resource.Name +
+ @GetSyncBadge(resource.SyncStatus) + @GetHealthBadge(resource.HealthStatus) +
+
+ @if (resource.ChildResources?.Count > 0) + { + @foreach (DeploymentResource child in resource.ChildResources) + { + @RenderResource(child, depth + 1) + } + } + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/AppPanel.razor new file mode 100644 index 0000000..fc6fe54 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/AppPanel.razor @@ -0,0 +1,148 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService + +
+
+ Apps for @Customer.Name + +
+
+
+ + +
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + + @if (apps is null) + { +

Loading...

+ } + else if (apps.Count == 0) + { +

No apps yet for this customer.

+ } + else + { + + + + + + + + + + @foreach (Data.App app in apps) + { + + + + + + + @if (envPickerAppId == app.Id && environments is not null) + { + + + + } + } + +
App NameEnvironments
@app.Name + @if (app.AppEnvironments.Any()) + { + @string.Join(", ", app.AppEnvironments.Select(ae => ae.Environment.Name)) + } + else + { + None + } + + + + +
+
+ @foreach (Data.Environment env in environments) + { + bool isLinked = app.AppEnvironments.Any(ae => ae.EnvironmentId == env.Id); + + } +
+
+ } +
+
+ +@code { + [Parameter] public Customer Customer { get; set; } = null!; + [Parameter] public Guid TenantId { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private List? apps; + private List? environments; + private string newAppName = ""; + private string? errorMessage; + private Guid? envPickerAppId; + + protected override async Task OnInitializedAsync() + { + await Load(); + environments = await TenantService.GetEnvironmentsAsync(TenantId); + } + + private async Task Load() + { + apps = await TenantService.GetAppsAsync(Customer.Id); + } + + private async Task CreateApp() + { + errorMessage = null; + + try + { + await TenantService.CreateAppAsync(Customer.Id, newAppName.Trim()); + newAppName = ""; + await Load(); + } + catch (DbUpdateException) + { + errorMessage = "An app with that name already exists for this customer."; + } + } + + private async Task DeleteApp(Guid id) + { + await TenantService.DeleteAppAsync(id); + await Load(); + } + + private void ToggleEnvPicker(Guid appId) + { + envPickerAppId = envPickerAppId == appId ? null : appId; + } + + private async Task ToggleEnvironment(Guid appId, Guid envId, bool currentlyLinked) + { + if (currentlyLinked) + { + await TenantService.UnlinkAppFromEnvironmentAsync(appId, envId); + } + else + { + await TenantService.LinkAppToEnvironmentAsync(appId, envId); + } + + await Load(); + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor new file mode 100644 index 0000000..3dfbb63 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor @@ -0,0 +1,2043 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService +@inject VaultService VaultService +@inject ComponentLifecycleService LifecycleService +@inject ExternalRouteService RouteService +@inject ComponentScanService ScanService + +@* ═══════════════════════════════════════════════════════════════════ + Cluster Detail — a full-page view for a single cluster. + Shows cluster info, components, and is the extension point + for future cluster-level features (health, logs, config, etc.). + ═══════════════════════════════════════════════════════════════════ *@ + +@* --- Back button --- *@ + + +@* --- Cluster header --- *@ +
+
+
+
+ +
+

@Cluster.Name

+
+ @if (!string.IsNullOrEmpty(Cluster.ContextName)) + { + Context: @Cluster.ContextName + } + @Cluster.ApiServerUrl +
+
+
+ + @if (!confirmDelete) + { + + } +
+ + @if (confirmDelete) + { +
+ + + Permanently delete @Cluster.Name and all its components? + +
+ + +
+
+ } +
+
+ +@* --- Cluster sub-sections (tabs for future expansion) --- *@ + + +@if (section == "monitoring") +{ + +} +else if (section == "components") +{ + @* --- Register Component --- *@ + @if (showRegisterForm) + { +
+
+ Add Component + +
+
+ @if (selectedCatalogEntry is null) + { + @* ── Step 1: Pick from catalog ── *@ +

+ Choose a component to add to this cluster. Configuration can be customised after selecting. +

+ @foreach (IGrouping group in ComponentCatalog.GetByCategory()) + { +
@group.Key
+
+ @foreach (CatalogEntry entry in group) + { + bool alreadyAdded = components?.Any(c => + ComponentCatalog.IsComponentMatch(c.Name, entry, c.HelmChartName, c.ReleaseName)) ?? false; + + List installedKeys = ComponentCatalog.ResolveInstalledKeys( + components?.Select(c => (c.Name, c.HelmChartName, c.ReleaseName)) ?? []); + DependencyCheckResult depCheck = ComponentCatalog.CheckDependencies(entry, installedKeys); + +
+
+
+
+ + @entry.DisplayName + @if (alreadyAdded) + { + Added + } + else if (!depCheck.IsSatisfied) + { + + Deps + + } +
+

@entry.Description

+ @if (entry.Dependencies.Count > 0 || entry.RequiresOneOf.Count > 0) + { +
+ @foreach (string dep in entry.Dependencies) + { + bool met = installedKeys.Any(n => + string.Equals(n, dep, StringComparison.OrdinalIgnoreCase)); + + @dep + + } + @foreach (DependencyRequirement req in entry.RequiresOneOf) + { + bool met = req.Options.Any(opt => + installedKeys.Any(n => string.Equals(n, opt, StringComparison.OrdinalIgnoreCase))); + + @req.Label + + } +
+ } +
+
+
+ } +
+ } + + @* ── Custom component option ── *@ +
Custom
+
+
+
+
+
+ + Custom Helm Chart +
+

Add any Helm chart by specifying the repo URL, chart name, and version manually.

+
+
+
+
+ + @* ── Custom form (shown inline when "Custom" is clicked) ── *@ + @if (showCustomForm) + { +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+ } + } + else + { + @* ── Step 2: Review & customise before adding ── *@ +
+ + +
@selectedCatalogEntry.DisplayName
+ @selectedCatalogEntry.Category +
+ +

@selectedCatalogEntry.Description

+ + @* ── Dependency status ── *@ + @if (!GetDependencyCheck(selectedCatalogEntry).IsSatisfied) + { +
+ Missing dependencies — add these components first: +
    + @foreach (string missing in GetDependencyCheck(selectedCatalogEntry).MissingDependencies) + { + CatalogEntry? missingEntry = ComponentCatalog.GetByKey(missing); +
  • + @(missingEntry?.DisplayName ?? missing) + @if (missingEntry is not null) + { + — @missingEntry.Description + } +
  • + } + @foreach (DependencyRequirement missingReq in GetDependencyCheck(selectedCatalogEntry).MissingOneOfRequirements) + { +
  • + @missingReq.Label — one of: + @string.Join(", ", missingReq.Options.Select(o => ComponentCatalog.GetByKey(o)?.DisplayName ?? o)) +
  • + } +
+
+ } + else if (selectedCatalogEntry.Dependencies.Count > 0 || selectedCatalogEntry.RequiresOneOf.Count > 0) + { +
+ All dependencies are satisfied. +
+ } + +
+
+ + +
+
+ + +
+
+ + @if (selectedCatalogEntry.FormFields.Count > 0) + { +
+ @foreach (ComponentFormField field in selectedCatalogEntry.FormFields) + { +
+ + @if (field.Type == FormFieldType.Toggle) + { +
+ +
+ } + else if (field.Type == FormFieldType.Select && field.Options is not null) + { + + } + else if (field.Type == FormFieldType.Password) + { + + } + else if (field.Type == FormFieldType.Number) + { + + } + else + { + + } + @if (!string.IsNullOrEmpty(field.HelpText)) + { + @field.HelpText + } +
+ } +
+ } + +
+
+

+ +

+ @if (showAdvancedYaml) + { +
+ +
+ } +
+
+ + + } +
+
+ } + else + { +
+ + +
+ } + + @if (!string.IsNullOrEmpty(componentError)) + { +
+ @componentError +
+ } + + @* --- Scan Results Panel --- *@ + @if (discoveredReleases is not null && discoveredReleases.Count > 0) + { +
+
+ + + Discovered Helm Releases + @discoveredReleases.Count + +
+ @{ + int newCount = discoveredReleases.Count(r => !r.AlreadyTracked); + } + @if (newCount > 0) + { + + } + +
+
+
+
+ + + + + + + + + + + + + + @foreach (DiscoveredHelmRelease release in discoveredReleases) + { + + + + + + + + + + } + +
ReleaseNamespaceChartVersionStatusRevAction
@release.Name@release.Namespace@(release.ChartName ?? "—")@(release.ChartVersion ?? "—") + @if (release.Status == "deployed") + { + deployed + } + else if (release.Status == "failed") + { + failed + } + else + { + @(release.Status ?? "unknown") + } + @release.Revision + @if (release.AlreadyTracked) + { + Tracked + } + else + { + + } +
+
+
+
+ } + else if (discoveredReleases is not null && discoveredReleases.Count == 0) + { +
+ No Helm releases found on this cluster. +
+ } + + @* --- Component List --- *@ + @if (components is null) + { +
+
+
+ } + else if (components.Count == 0) + { +
+ +

No components on this cluster yet. Register one above.

+
+ } + else + { +
+ @foreach (ClusterComponent comp in components) + { +
+
+ @* ── Card Header ── *@ +
+
+
+ @{ + CatalogEntry? headerCatalog = ComponentCatalog.GetByKey(comp.Name); + string icon = headerCatalog?.Icon ?? "bi-puzzle"; + } + +
+ @(headerCatalog?.DisplayName ?? comp.Name) +
+ @GetStatusBadge(comp.Status) + @if (!string.IsNullOrEmpty(comp.Namespace)) + { + @comp.Namespace + } + @if (!string.IsNullOrEmpty(comp.HelmChartVersion)) + { + v@(comp.HelmChartVersion) + } + @if (comp.InstalledAt.HasValue) + { + @comp.InstalledAt.Value.ToString("g") + } +
+
+
+
+ @* ── Lifecycle action buttons ── *@ + @if (operationInProgress == comp.Id) + { +
+ + + @(comp.Status == ComponentStatus.Uninstalling ? "Uninstalling..." : "Installing...") + +
+ } + else if (comp.Status == ComponentStatus.NotInstalled || comp.Status == ComponentStatus.Failed) + { + + } + else if (comp.Status == ComponentStatus.Installed) + { + + + } + +
+
+
+ + @* ── Error / last failure ── *@ + @if (!string.IsNullOrEmpty(comp.LastError)) + { +
+
+ +
+ Last operation failed +
@comp.LastError
+
+ +
+
+ } + + @* ── Confirm uninstall/remove ── *@ + @if (confirmDeleteComponentId == comp.Id) + { +
+
+
+ + + @if (comp.Status == ComponentStatus.Installed) + { + Uninstall @(headerCatalog?.DisplayName ?? comp.Name) from the cluster? + } + else + { + Remove @(headerCatalog?.DisplayName ?? comp.Name) from the list? + } + +
+
+ @if (comp.Status == ComponentStatus.Installed) + { + + } + + +
+
+
+ } + + @* ── Operation output ── *@ + @if (operationOutput is not null && operationComponentId == comp.Id) + { +
+
+ + Helm Output + + +
+
@operationOutput
+
+ } + + @* ── Expanded detail panel ── *@ + @if (selectedComponentId == comp.Id) + { +
+ @* Sub-tabs *@ + + + @if (compDetailTab == "config") + { + @* ── Configuration ── *@ + + @* Form fields first (if catalog component) *@ + @if (GetCatalogMatchForSelected()?.FormFields.Count > 0) + { +
+ @foreach (ComponentFormField field in GetCatalogMatchForSelected()!.FormFields) + { +
+ + @if (field.Type == FormFieldType.Toggle) + { +
+ +
+ } + else if (field.Type == FormFieldType.Select && field.Options is not null) + { + + } + else if (field.Type == FormFieldType.Password) + { + + } + else if (field.Type == FormFieldType.Number) + { + + } + else + { + + } + @if (!string.IsNullOrEmpty(field.HelpText)) + { + @field.HelpText + } +
+ } +
+ } + + @* Helm chart details (collapsible for catalog components) *@ +
+
+

+ +

+ @if (showHelmDetails) + { +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ } +
+
+ + @* Advanced YAML editor *@ +
+
+

+ +

+ @if (showAdvancedYaml) + { +
+ +
+ } +
+
+ + @* Action bar *@ +
+ + @if (comp.Status == ComponentStatus.NotInstalled || comp.Status == ComponentStatus.Failed) + { + + } + else if (comp.Status == ComponentStatus.Installed) + { + + } +
+ } + else if (compDetailTab == "secrets") + { + @* ── Secrets management (existing functionality) ── *@ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + @if (componentSecrets is null) + { +
+
+
+ } + else if (componentSecrets.Count == 0) + { +

No secrets for this component yet.

+ } + else + { +
+ + + + + + + + + + + + @foreach (VaultSecret secret in componentSecrets) + { + + + + + + + + + @if (syncConfigSecretId == secret.Id) + { + + + + } + } + +
NameK8s SyncTarget SecretNamespaceActions
@secret.Name + @if (secret.SyncToKubernetes) + { + Synced + } + else + { + Off + } + @(secret.KubernetesSecretName ?? "—")@(secret.KubernetesNamespace ?? "—") + + +
+
+
+ +
+
+ +
+
+ + +
+
+
+
+ + @if (componentSecrets.Any(s => s.SyncToKubernetes)) + { +
+ + @if (!string.IsNullOrWhiteSpace(secretSyncOutput)) + { + @secretSyncOutput + } +
+ } + } + } + else if (compDetailTab == "expose") + { + @* ── External Routes management ── *@ +
+
+ Expose Externally +
+

+ Add a hostname to expose this component to the internet via Gateway API with automatic or manual TLS. +

+ + @* ── Add route form ── *@ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ @if (routeTlsMode == "ClusterIssuer") + { +
+ + +
+ } + else + { +
+ + +
+ } +
+ @if (routeTlsMode == "Manual") + { +
+
+ + +
+
+ } + +
+
+ + @if (!string.IsNullOrEmpty(routeError)) + { +
+ @routeError +
+ } + + @* ── Existing routes ── *@ + @if (componentRoutes is null) + { +
+
+
+ } + else if (componentRoutes.Count == 0) + { +

No external routes configured yet.

+ } + else + { +
+ + + + + + + + + + + @foreach (ExternalRoute route in componentRoutes) + { + + + + + + + } + +
HostnameServiceTLSActions
+ + @route.Hostname + + @route.ServiceName:@route.ServicePort + @if (route.TlsMode == TlsMode.ClusterIssuer) + { + + @route.ClusterIssuerName + + } + else + { + + Manual + + } + + + +
+
+ } + + @* ── YAML preview ── *@ + @if (routeYamlPreview is not null) + { +
+
+ HTTPRoute YAML + +
+
@routeYamlPreview
+
+ } +
+ } +
+ } +
+
+ } +
+ } +} + +@code { + [Parameter] public required KubernetesCluster Cluster { get; set; } + [Parameter] public Guid TenantId { get; set; } + [Parameter] public EventCallback OnBack { get; set; } + [Parameter] public EventCallback OnDeleted { get; set; } + + private string section = "monitoring"; + private bool confirmDelete; + + // Components + private List? components; + private string? componentError; + private Guid? confirmDeleteComponentId; + private Guid? selectedComponentId; + private string compDetailTab = "config"; + private Guid? operationInProgress; + private string? operationOutput; + private Guid? operationComponentId; + + // Scan + private bool scanning; + private List? discoveredReleases; + + // Register form + private bool showRegisterForm; + private CatalogEntry? selectedCatalogEntry; + private bool showCustomForm; + private string regName = ""; + private string regType = "HelmChart"; + private string regNamespace = ""; + private string regRepoUrl = ""; + private string regChartName = ""; + private string regChartVersion = ""; + private string regReleaseName = ""; + private string regValues = ""; + + // Form fields (registration and edit) + private bool showAdvancedYaml; + private bool showHelmDetails; + private Dictionary regFormFieldValues = new(); + private Dictionary editFormFieldValues = new(); + + // Config editor + private string editRepoUrl = ""; + private string editChartName = ""; + private string editChartVersion = ""; + private string editNamespace = ""; + private string editReleaseName = ""; + private string editValues = ""; + + // Secrets + private List? componentSecrets; + private string newSecretName = ""; + private string newSecretValue = ""; + private Guid? syncConfigSecretId; + private string syncSecretName = ""; + private string syncNamespace = ""; + private bool secretSyncInProgress; + private string? secretSyncOutput; + + // External Routes + private List? componentRoutes; + private string routeHostname = ""; + private string routeServiceName = ""; + private int routeServicePort = 80; + private string routeTlsMode = "ClusterIssuer"; + private string routeIssuerName = "letsencrypt-prod"; + private string routeTlsCert = ""; + private string routeTlsKey = ""; + private string? routeError; + private string? routeYamlPreview; + + protected override async Task OnInitializedAsync() + { + await LoadComponents(); + } + + // ──────── Cluster ──────── + + private async Task DeleteCluster() + { + await TenantService.DeleteClusterAsync(Cluster.Id); + await OnDeleted.InvokeAsync(); + } + + // ──────── Components ──────── + + private async Task LoadComponents() + { + components = await VaultService.GetComponentsAsync(Cluster.Id); + } + + // ──────── Scan ──────── + + private async Task ScanClusterComponents() + { + scanning = true; + componentError = null; + + try + { + discoveredReleases = await ScanService.ScanHelmReleasesAsync(Cluster); + } + catch (Exception ex) + { + componentError = $"Scan failed: {ex.Message}"; + } + finally + { + scanning = false; + } + } + + private async Task ImportSingleRelease(DiscoveredHelmRelease release) + { + try + { + await ScanService.ImportReleaseAsync(Cluster, release); + release.AlreadyTracked = true; + await LoadComponents(); + } + catch (Exception ex) + { + componentError = $"Import failed: {ex.Message}"; + } + } + + private async Task ImportAllDiscovered() + { + if (discoveredReleases is null) + { + return; + } + + try + { + await ScanService.ImportAllNewReleasesAsync(Cluster, discoveredReleases); + await LoadComponents(); + } + catch (Exception ex) + { + // ImportAllNewReleasesAsync throws with a detailed message including + // inner exception details and which specific releases failed. + componentError = $"Import failed: {ex.Message}"; + await LoadComponents(); + } + } + + private void ResetRegisterForm() + { + selectedCatalogEntry = null; + showCustomForm = false; + regName = ""; + regType = "HelmChart"; + regNamespace = ""; + regRepoUrl = ""; + regChartName = ""; + regChartVersion = ""; + regReleaseName = ""; + regValues = ""; + regFormFieldValues = new(); + showAdvancedYaml = false; + } + + private DependencyCheckResult GetDependencyCheck(CatalogEntry entry) + { + List installedNames = components?.Select(c => c.Name).ToList() ?? []; + return ComponentCatalog.CheckDependencies(entry, installedNames); + } + + private void SelectCatalogEntry(CatalogEntry entry) + { + // Pre-fill the form fields from the catalog entry so the operator + // only needs to tweak values, not enter everything from scratch. + + selectedCatalogEntry = entry; + regNamespace = entry.DefaultNamespace; + regReleaseName = entry.DefaultReleaseName ?? entry.Key; + regValues = entry.DefaultValues ?? ""; + showAdvancedYaml = false; + + // Initialize form field values from their defaults. + + regFormFieldValues = new(); + + foreach (ComponentFormField field in entry.FormFields) + { + if (field.DefaultValue is not null) + { + regFormFieldValues[field.Key] = field.DefaultValue; + } + } + } + + private async Task RegisterCatalogComponent() + { + // Build a registration from the selected catalog entry, applying + // any overrides the operator made via form fields or raw YAML. + // Secret-marked fields are stored in the vault after registration. + + componentError = null; + + try + { + ComponentRegistration registration = ComponentCatalog.ToRegistration(selectedCatalogEntry!); + registration.Namespace = string.IsNullOrWhiteSpace(regNamespace) ? null : regNamespace.Trim(); + registration.ReleaseName = string.IsNullOrWhiteSpace(regReleaseName) ? null : regReleaseName.Trim(); + + // Merge form field values into the YAML before saving. + // Form fields map to specific YAML paths via dot-notation. + // Secret fields are excluded from the YAML (they go to vault). + + string mergedValues = MergeRegFormFields(); + registration.HelmValues = string.IsNullOrWhiteSpace(mergedValues) ? null : mergedValues; + + ClusterComponent created = await LifecycleService.RegisterComponentAsync(Cluster.Id, registration); + + // Store any secret-marked form fields in the vault. + + await SaveRegSecretFieldsToVault(created.Id); + + showRegisterForm = false; + ResetRegisterForm(); + await LoadComponents(); + } + catch (InvalidOperationException ex) + { + componentError = ex.Message; + } + } + + private async Task RegisterCustomComponent() + { + // For custom charts the operator fills in everything manually. + + componentError = null; + + try + { + ComponentRegistration registration = new() + { + Name = regName.Trim(), + ComponentType = regType, + Namespace = string.IsNullOrWhiteSpace(regNamespace) ? null : regNamespace.Trim(), + HelmRepoUrl = string.IsNullOrWhiteSpace(regRepoUrl) ? null : regRepoUrl.Trim(), + HelmChartName = string.IsNullOrWhiteSpace(regChartName) ? null : regChartName.Trim(), + HelmChartVersion = string.IsNullOrWhiteSpace(regChartVersion) ? null : regChartVersion.Trim(), + HelmValues = string.IsNullOrWhiteSpace(regValues) ? null : regValues + }; + + await LifecycleService.RegisterComponentAsync(Cluster.Id, registration); + showRegisterForm = false; + ResetRegisterForm(); + await LoadComponents(); + } + catch (InvalidOperationException ex) + { + componentError = ex.Message; + } + } + + private void ToggleComponentDetail(Guid componentId) + { + if (selectedComponentId == componentId) + { + selectedComponentId = null; + return; + } + + selectedComponentId = componentId; + compDetailTab = "config"; + componentSecrets = null; + showAdvancedYaml = false; + showHelmDetails = false; + + // Populate the config editor fields from the component. + + ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == componentId); + if (comp is not null) + { + editRepoUrl = comp.HelmRepoUrl ?? ""; + editChartName = comp.HelmChartName ?? ""; + editChartVersion = comp.HelmChartVersion ?? ""; + editNamespace = comp.Namespace ?? ""; + editReleaseName = comp.ReleaseName ?? comp.Name; + editValues = comp.HelmValues ?? ""; + + // Initialize edit form fields from the catalog entry defaults, + // then override with any values that exist in the current YAML. + + InitEditFormFields(comp); + } + } + + private async Task SaveConfiguration(Guid componentId) + { + componentError = null; + + try + { + // Update the component with the edited configuration. + // Form field values are merged into the YAML before saving. + // Secret-marked fields are stored in the vault instead of plain YAML. + + ClusterComponent comp = components!.First(c => c.Id == componentId); + comp.HelmRepoUrl = string.IsNullOrWhiteSpace(editRepoUrl) ? null : editRepoUrl.Trim(); + comp.HelmChartName = string.IsNullOrWhiteSpace(editChartName) ? null : editChartName.Trim(); + comp.HelmChartVersion = string.IsNullOrWhiteSpace(editChartVersion) ? null : editChartVersion.Trim(); + comp.Namespace = string.IsNullOrWhiteSpace(editNamespace) ? null : editNamespace.Trim(); + comp.ReleaseName = string.IsNullOrWhiteSpace(editReleaseName) ? null : editReleaseName.Trim(); + + string mergedValues = MergeEditFormFields(); + comp.HelmValues = string.IsNullOrWhiteSpace(mergedValues) ? null : mergedValues; + + await LifecycleService.UpdateConfigurationAsync( + componentId, + helmValues: comp.HelmValues, + chartVersion: comp.HelmChartVersion, + helmRepoUrl: comp.HelmRepoUrl); + + // Store any secret-marked form field values in the vault. + + await SaveSecretFieldsToVault(componentId); + await LoadComponents(); + } + catch (InvalidOperationException ex) + { + componentError = ex.Message; + } + } + + private async Task InstallComponent(Guid componentId) + { + componentError = null; + operationInProgress = componentId; + StateHasChanged(); + + try + { + await LifecycleService.PrepareInstallAsync(componentId); + HelmCommand command = await LifecycleService.GetInstallCommandAsync(componentId); + HelmExecutionResult result = await LifecycleService.ExecuteHelmAsync(componentId, command); + + await LifecycleService.MarkInstallResultAsync(componentId, result.Success, result.Success ? null : result.Output); + + // After successful install, install any companion charts + // (e.g. Barman Cloud Plugin alongside CloudNativePG). + + if (result.Success) + { + List companionResults = await LifecycleService.InstallCompanionsAsync(componentId); + + foreach (HelmExecutionResult cr in companionResults) + { + if (!string.IsNullOrWhiteSpace(cr.Output)) + { + result = new HelmExecutionResult + { + Success = result.Success && cr.Success, + Output = result.Output + "\n\n--- Companion Install ---\n" + cr.Output + }; + } + } + } + + // After successful install, sync any secrets marked for K8s sync. + + if (result.Success) + { + HelmExecutionResult syncResult = await LifecycleService.SyncComponentSecretsAsync(componentId); + + if (!string.IsNullOrWhiteSpace(syncResult.Output)) + { + result = new HelmExecutionResult + { + Success = result.Success, + Output = result.Output + "\n\n--- Secret Sync ---\n" + syncResult.Output + }; + } + } + + operationOutput = result.Output; + operationComponentId = componentId; + } + catch (InvalidOperationException ex) + { + componentError = ex.Message; + } + finally + { + operationInProgress = null; + await LoadComponents(); + } + } + + private async Task UpgradeComponent(Guid componentId) + { + componentError = null; + operationInProgress = componentId; + StateHasChanged(); + + try + { + // For upgrade, we use the same install command (upgrade --install is idempotent). + // But first reset status to allow the operation. + + ClusterComponent comp = components!.First(c => c.Id == componentId); + comp.Status = ComponentStatus.Installing; + await LifecycleService.MarkInstallResultAsync(componentId, false, "Upgrading..."); + + await LifecycleService.PrepareInstallAsync(componentId); + HelmCommand command = await LifecycleService.GetInstallCommandAsync(componentId); + HelmExecutionResult result = await LifecycleService.ExecuteHelmAsync(componentId, command); + + await LifecycleService.MarkInstallResultAsync(componentId, result.Success, result.Success ? null : result.Output); + + // After successful install/upgrade, install any companion charts + // (e.g. Barman Cloud Plugin alongside CloudNativePG). + + if (result.Success) + { + List companionResults = await LifecycleService.InstallCompanionsAsync(componentId); + + foreach (HelmExecutionResult cr in companionResults) + { + if (!string.IsNullOrWhiteSpace(cr.Output)) + { + result = new HelmExecutionResult + { + Success = result.Success && cr.Success, + Output = result.Output + "\n\n--- Companion Install ---\n" + cr.Output + }; + } + } + } + + // After successful upgrade, sync any secrets marked for K8s sync. + + if (result.Success) + { + HelmExecutionResult syncResult = await LifecycleService.SyncComponentSecretsAsync(componentId); + + if (!string.IsNullOrWhiteSpace(syncResult.Output)) + { + result = new HelmExecutionResult + { + Success = result.Success, + Output = result.Output + "\n\n--- Secret Sync ---\n" + syncResult.Output + }; + } + } + + operationOutput = result.Output; + operationComponentId = componentId; + } + catch (InvalidOperationException ex) + { + componentError = ex.Message; + } + finally + { + operationInProgress = null; + await LoadComponents(); + } + } + + private async Task UninstallComponent(Guid componentId) + { + componentError = null; + operationInProgress = componentId; + StateHasChanged(); + + try + { + await LifecycleService.PrepareUninstallAsync(componentId); + HelmCommand command = await LifecycleService.GetUninstallCommandAsync(componentId); + HelmExecutionResult result = await LifecycleService.ExecuteHelmAsync(componentId, command); + + await LifecycleService.MarkUninstallResultAsync(componentId, result.Success, result.Success ? null : result.Output); + + operationOutput = result.Output; + operationComponentId = componentId; + } + catch (InvalidOperationException ex) + { + componentError = ex.Message; + } + finally + { + operationInProgress = null; + await LoadComponents(); + } + } + + private async Task SaveAndInstall(Guid componentId) + { + // Save the configuration first, then immediately install. + // This is the "one-click" flow for new components. + + await SaveConfiguration(componentId); + + if (componentError is null) + { + await InstallComponent(componentId); + } + } + + private async Task SaveAndApply(Guid componentId) + { + // Save the configuration, then run a helm upgrade to apply + // the changes to the live deployment. + + await SaveConfiguration(componentId); + + if (componentError is null) + { + await UpgradeComponent(componentId); + } + } + + private async Task ClearError(Guid componentId) + { + // Clear the LastError without changing the component's status. + + await LifecycleService.ClearErrorAsync(componentId); + await LoadComponents(); + } + + private async Task DeleteComponent(Guid componentId) + { + confirmDeleteComponentId = null; + + if (selectedComponentId == componentId) + { + selectedComponentId = null; + } + + await VaultService.DeleteComponentAsync(componentId); + await LoadComponents(); + } + + private static RenderFragment GetStatusBadge(ComponentStatus status) => status switch + { + ComponentStatus.NotInstalled => __builder => + { + Not Installed + }, + ComponentStatus.Installing => __builder => + { + Installing + }, + ComponentStatus.Installed => __builder => + { + Installed + }, + ComponentStatus.Failed => __builder => + { + Failed + }, + ComponentStatus.Uninstalling => __builder => + { + Uninstalling + }, + _ => __builder => + { + Unknown + } + }; + + // ──────── Secrets ──────── + + private async Task ShowSecrets(Guid componentId) + { + compDetailTab = "secrets"; + newSecretName = ""; + newSecretValue = ""; + + await VaultService.InitializeVaultAsync(TenantId); + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private async Task AddSecret(Guid componentId) + { + await VaultService.SetComponentSecretAsync(TenantId, componentId, newSecretName.Trim(), newSecretValue.Trim()); + newSecretName = ""; + newSecretValue = ""; + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private async Task DeleteSecret(Guid secretId, Guid componentId) + { + await VaultService.DeleteSecretAsync(secretId); + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private void ToggleSecretSync(VaultSecret secret) + { + if (secret.SyncToKubernetes) + { + _ = DisableSync(secret); + } + else + { + syncConfigSecretId = secret.Id; + syncSecretName = secret.KubernetesSecretName ?? ""; + syncNamespace = secret.KubernetesNamespace ?? ""; + } + } + + private async Task DisableSync(VaultSecret secret) + { + await VaultService.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: false, secretName: null, ns: null); + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, selectedComponentId!.Value); + } + + private async Task SaveSync(Guid componentId) + { + if (syncConfigSecretId is null) + { + return; + } + + await VaultService.ConfigureKubernetesSyncAsync( + syncConfigSecretId.Value, syncEnabled: true, secretName: syncSecretName.Trim(), ns: syncNamespace.Trim()); + + syncConfigSecretId = null; + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private async Task SyncSecretsToCluster(Guid componentId) + { + // Manually trigger a sync of all K8s-marked secrets to the cluster. + + secretSyncInProgress = true; + secretSyncOutput = null; + StateHasChanged(); + + try + { + HelmExecutionResult result = await LifecycleService.SyncComponentSecretsAsync(componentId); + secretSyncOutput = result.Success ? "Synced successfully" : result.Output; + } + catch (Exception ex) + { + secretSyncOutput = $"Sync failed: {ex.Message}"; + } + finally + { + secretSyncInProgress = false; + } + } + + // ──────── Form Field Helpers ──────── + + private string GetFormFieldValue(string key) + { + return regFormFieldValues.TryGetValue(key, out string? value) ? value : ""; + } + + private bool IsFormFieldTrue(string key) + { + return GetFormFieldValue(key) == "true"; + } + + private void SetFormFieldValue(string key, string value) + { + regFormFieldValues[key] = value; + } + + private string GetEditFormFieldValue(string key) + { + return editFormFieldValues.TryGetValue(key, out string? value) ? value : ""; + } + + private bool IsEditFormFieldTrue(string key) + { + return GetEditFormFieldValue(key) == "true"; + } + + private void SetEditFormFieldValue(string key, string value) + { + editFormFieldValues[key] = value; + } + + private void OnRegToggleChanged(string key, ChangeEventArgs e) + { + string val = e.Value?.ToString() == "True" ? "true" : "false"; + SetFormFieldValue(key, val); + } + + private void OnRegFieldChanged(string key, ChangeEventArgs e) + { + SetFormFieldValue(key, e.Value?.ToString() ?? string.Empty); + } + + private void OnEditToggleChanged(string key, ChangeEventArgs e) + { + string val = e.Value?.ToString() == "True" ? "true" : "false"; + SetEditFormFieldValue(key, val); + } + + private void OnEditFieldChanged(string key, ChangeEventArgs e) + { + SetEditFormFieldValue(key, e.Value?.ToString() ?? string.Empty); + } + + private CatalogEntry? GetCatalogMatchForSelected() + { + ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == selectedComponentId); + return comp is not null ? ComponentCatalog.GetByKey(comp.Name) : null; + } + + private string MergeRegFormFields() + { + // Build a path-to-value dictionary from form fields, then merge into YAML. + // Secret fields are excluded — they live in the vault, not in plain YAML. + + if (selectedCatalogEntry is null) + { + return regValues; + } + + Dictionary pathValues = new(); + + foreach (ComponentFormField field in selectedCatalogEntry.FormFields) + { + if (field.StoreAsSecret) + { + continue; + } + + if (regFormFieldValues.TryGetValue(field.Key, out string? val) && !string.IsNullOrEmpty(val)) + { + pathValues[field.YamlPath] = val; + } + } + + if (pathValues.Count == 0) + { + return regValues; + } + + return YamlFormMerger.MergeFormValues(regValues, pathValues); + } + + private string MergeEditFormFields() + { + // Same as MergeRegFormFields but for the edit form (installed components). + // Secret fields are excluded — they live in the vault, not in plain YAML. + + ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == selectedComponentId); + CatalogEntry? catalogMatch = comp is not null ? ComponentCatalog.GetByKey(comp.Name) : null; + + if (catalogMatch is null || catalogMatch.FormFields.Count == 0) + { + return editValues; + } + + Dictionary pathValues = new(); + + foreach (ComponentFormField field in catalogMatch.FormFields) + { + if (field.StoreAsSecret) + { + continue; + } + + if (editFormFieldValues.TryGetValue(field.Key, out string? val) && !string.IsNullOrEmpty(val)) + { + pathValues[field.YamlPath] = val; + } + } + + if (pathValues.Count == 0) + { + return editValues; + } + + return YamlFormMerger.MergeFormValues(editValues, pathValues); + } + + /// + /// Stores secret-marked form fields from the edit form into the vault. + /// Called during SaveConfiguration for already-registered components. + /// + private async Task SaveSecretFieldsToVault(Guid componentId) + { + ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == componentId); + CatalogEntry? catalogMatch = comp is not null ? ComponentCatalog.GetByKey(comp.Name) : null; + + if (catalogMatch is null) + { + return; + } + + await VaultService.InitializeVaultAsync(TenantId); + + foreach (ComponentFormField field in catalogMatch.FormFields) + { + if (!field.StoreAsSecret) + { + continue; + } + + if (editFormFieldValues.TryGetValue(field.Key, out string? val) && !string.IsNullOrEmpty(val)) + { + string secretName = field.SecretName ?? field.Key; + await VaultService.SetComponentSecretAsync(TenantId, componentId, secretName, val); + } + } + } + + /// + /// Stores secret-marked form fields from the registration form into the vault. + /// Called after initial component registration. + /// + private async Task SaveRegSecretFieldsToVault(Guid componentId) + { + if (selectedCatalogEntry is null) + { + return; + } + + await VaultService.InitializeVaultAsync(TenantId); + + foreach (ComponentFormField field in selectedCatalogEntry.FormFields) + { + if (!field.StoreAsSecret) + { + continue; + } + + if (regFormFieldValues.TryGetValue(field.Key, out string? val) && !string.IsNullOrEmpty(val)) + { + string secretName = field.SecretName ?? field.Key; + await VaultService.SetComponentSecretAsync(TenantId, componentId, secretName, val); + } + } + } + + private void InitEditFormFields(ClusterComponent comp) + { + // Look up the catalog entry to get form field definitions, + // then initialize from defaults (or extract from existing YAML). + + editFormFieldValues = new(); + CatalogEntry? catalogMatch = ComponentCatalog.GetByKey(comp.Name); + + if (catalogMatch is null) + { + return; + } + + foreach (ComponentFormField field in catalogMatch.FormFields) + { + editFormFieldValues[field.Key] = field.DefaultValue ?? ""; + } + + // Try to extract current values from the component's YAML. + // If the YAML has the value, it overrides the default. + + if (!string.IsNullOrWhiteSpace(comp.HelmValues)) + { + foreach (ComponentFormField field in catalogMatch.FormFields) + { + string? extracted = YamlFormMerger.ExtractValue(comp.HelmValues, field.YamlPath); + + if (extracted is not null) + { + editFormFieldValues[field.Key] = extracted; + } + } + } + } + + // ──────── External Routes ──────── + + private async Task ShowRoutes(Guid componentId) + { + compDetailTab = "expose"; + routeError = null; + routeYamlPreview = null; + componentRoutes = await RouteService.GetRoutesAsync(componentId); + } + + private async Task AddRoute(Guid componentId) + { + routeError = null; + + try + { + ExternalRouteRequest request = new() + { + Hostname = routeHostname.Trim(), + ServiceName = string.IsNullOrWhiteSpace(routeServiceName) ? null : routeServiceName.Trim(), + ServicePort = routeServicePort, + TlsMode = routeTlsMode == "Manual" ? TlsMode.Manual : TlsMode.ClusterIssuer, + ClusterIssuerName = routeTlsMode == "ClusterIssuer" ? routeIssuerName.Trim() : null, + TlsCertificate = routeTlsMode == "Manual" ? routeTlsCert : null, + TlsPrivateKey = routeTlsMode == "Manual" && !string.IsNullOrWhiteSpace(routeTlsKey) ? routeTlsKey : null + }; + + await RouteService.AddRouteAsync(componentId, request); + + // Reset form fields after successful add. + + routeHostname = ""; + routeServiceName = ""; + routeServicePort = 80; + routeTlsCert = ""; + routeTlsKey = ""; + + componentRoutes = await RouteService.GetRoutesAsync(componentId); + } + catch (InvalidOperationException ex) + { + routeError = ex.Message; + } + } + + private async Task DeleteRoute(Guid routeId, Guid componentId) + { + await RouteService.DeleteRouteAsync(routeId); + componentRoutes = await RouteService.GetRoutesAsync(componentId); + routeYamlPreview = null; + } + + private async Task ShowRouteYaml(Guid routeId) + { + routeYamlPreview = await RouteService.GenerateHttpRouteYamlAsync(routeId); + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/ClusterHealthPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/ClusterHealthPanel.razor new file mode 100644 index 0000000..a182d79 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/ClusterHealthPanel.razor @@ -0,0 +1,216 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject PrometheusService PrometheusService + +
+
+ Cluster Health — Prometheus +
+ @if (loading) + { + + } + + + +
+
+
+ @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ @errorMessage +
+ } + else if (health is null && loading) + { +

Querying Prometheus...

+ } + else if (health is not null) + { + @* ── Overview cards ── *@ +
+
+
+
+
+ @health.ReadyNodes / @health.TotalNodes +
+ Nodes Ready +
+
+
+
+
+
+
@health.RunningPods
+ Running Pods + @if (health.PendingPods > 0 || health.FailedPods > 0) + { +
+ @if (health.PendingPods > 0) + { + @health.PendingPods pending + } + + @if (health.FailedPods > 0) + { + @health.FailedPods failed + } +
+ } +
+
+
+
+
+
+
+ @health.CpuUsagePercent% +
+ CPU Usage +
+
+
+
+
+
+
+
+
+
+ @health.MemoryUsagePercent% +
+ Memory Usage +
+
+
+
+
+
+
+ + @* ── Per-node table ── *@ + @if (health.Nodes.Count > 0) + { +
Nodes
+ + + + + + + + + + + @foreach (NodeHealthInfo node in health.Nodes) + { + + + + + + + } + +
NodeStatusCPUMemory
@node.Name + @if (node.Ready) + { + Ready + } + else + { + NotReady + } + +
+
+
+
+ @node.CpuUsagePercent% +
+
+
+
+
+
+ @node.MemoryUsagePercent% +
+
+ } + +
+ Last queried: @health.QueriedAt.ToString("HH:mm:ss UTC") +
+ } + else + { +

No health data available. Click Refresh to query Prometheus.

+ } +
+
+ +@code { + [Parameter] public Guid ClusterId { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private ClusterHealthSummary? health; + private bool loading; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + await RefreshHealth(); + } + + private async Task RefreshHealth() + { + loading = true; + errorMessage = null; + StateHasChanged(); + + KubernetesOperationResult result = + await PrometheusService.GetClusterHealthAsync(ClusterId); + + if (result.IsSuccess) + { + health = result.Data; + } + else + { + errorMessage = result.Error; + } + + loading = false; + } + + private static string GetUsageBorderClass(double percent) => percent switch + { + >= 90 => "border-danger", + >= 70 => "border-warning", + _ => "border-success" + }; + + private static string GetUsageTextClass(double percent) => percent switch + { + >= 90 => "text-danger", + >= 70 => "text-warning", + _ => "text-success" + }; + + private static string GetProgressBarClass(double percent) => percent switch + { + >= 90 => "bg-danger", + >= 70 => "bg-warning", + _ => "bg-success" + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/ClusterMonitoring.razor b/src/EntKube.Web/Components/Pages/Tenants/ClusterMonitoring.razor new file mode 100644 index 0000000..c5d20e4 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/ClusterMonitoring.razor @@ -0,0 +1,318 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject PrometheusService PrometheusService + +@* ═══════════════════════════════════════════════════════════════════════════ + ClusterMonitoring — full monitoring dashboard for a single cluster. + Shows real-time metrics from Prometheus, time-series graphs, and + Alertmanager alerts/silences. The operations team's single pane of glass. + ═══════════════════════════════════════════════════════════════════════════ *@ + +
+ @if (loading && health is null) + { +
+
+

Connecting to Prometheus...

+
+ } + else if (!string.IsNullOrEmpty(errorMessage)) + { +
+ @errorMessage + +
+ } + else + { + @* ── Header with refresh ── *@ +
+
Monitoring
+
+ + @if (loading) + { + + } + +
+
+ + @* ── Status Overview Cards ── *@ + @if (health is not null) + { +
+
+
+
+
+ @health.ReadyNodes/@health.TotalNodes +
+ Nodes Ready +
+
+
+
+
+
+
@health.RunningPods
+ Running Pods + @if (health.PendingPods > 0 || health.FailedPods > 0) + { +
+ @if (health.PendingPods > 0) + { + @health.PendingPods pending + } + @if (health.FailedPods > 0) + { + @health.FailedPods failed + } +
+ } +
+
+
+
+
+
+
@health.CpuUsagePercent%
+ CPU Usage +
+
+
+
+
+
+
+
+
+
@health.MemoryUsagePercent%
+ Memory Usage +
+
+
+
+
+
+
+ } + + @* ── Time-Series Graphs ── *@ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ + @* ── Per-Node Status ── *@ + @if (health is not null && health.Nodes.Count > 0) + { +
+
+ Node Status +
+
+
+ + + + + + + + + + + @foreach (NodeHealthInfo node in health.Nodes) + { + + + + + + + } + +
NodeStatusCPUMemory
@node.Name + @if (node.Ready) + { + Ready + } + else + { + NotReady + } + +
+
+
+
+ @node.CpuUsagePercent% +
+
+
+
+
+
+ @node.MemoryUsagePercent% +
+
+
+
+
+ } + + @* ── Alerts ── *@ + + } +
+ +@code { + [Parameter] public Guid ClusterId { get; set; } + + private ClusterHealthSummary? health; + private List cpuHistory = []; + private List memHistory = []; + private List networkRxHistory = []; + private List podCountHistory = []; + private bool loading; + private string? errorMessage; + private int timeRange = 60; // minutes + + protected override async Task OnInitializedAsync() + { + await LoadAll(); + } + + private async Task LoadAll() + { + loading = true; + errorMessage = null; + StateHasChanged(); + + // Load the instant health summary and time-series data in parallel. + + Task healthTask = LoadHealth(); + Task metricsTask = LoadMetrics(); + await Task.WhenAll(healthTask, metricsTask); + + loading = false; + } + + private async Task LoadHealth() + { + KubernetesOperationResult result = + await PrometheusService.GetClusterHealthAsync(ClusterId); + + if (result.IsSuccess) + { + health = result.Data; + } + else + { + errorMessage = result.Error; + } + } + + private async Task LoadMetrics() + { + TimeSpan duration = TimeSpan.FromMinutes(timeRange); + + // Query multiple range metrics in parallel. + + Task>> cpuTask = + PrometheusService.GetMetricRangeAsync(ClusterId, + "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", duration); + + Task>> memTask = + PrometheusService.GetMetricRangeAsync(ClusterId, + "100 - (avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100)", duration); + + Task>> netTask = + PrometheusService.GetMetricRangeAsync(ClusterId, + "sum(rate(node_network_receive_bytes_total[5m])) / 1024 / 1024", duration); + + Task>> podTask = + PrometheusService.GetMetricRangeAsync(ClusterId, + "sum(kube_pod_status_phase{phase=\"Running\"})", duration); + + await Task.WhenAll(cpuTask, memTask, netTask, podTask); + + KubernetesOperationResult> cpuResult = await cpuTask; + KubernetesOperationResult> memResult = await memTask; + KubernetesOperationResult> netResult = await netTask; + KubernetesOperationResult> podResult = await podTask; + + // Extract the first series from each result (these are aggregated single-line metrics). + + cpuHistory = cpuResult.IsSuccess && cpuResult.Data!.Count > 0 + ? cpuResult.Data[0].DataPoints : []; + + memHistory = memResult.IsSuccess && memResult.Data!.Count > 0 + ? memResult.Data[0].DataPoints : []; + + networkRxHistory = netResult.IsSuccess && netResult.Data!.Count > 0 + ? netResult.Data[0].DataPoints : []; + + podCountHistory = podResult.IsSuccess && podResult.Data!.Count > 0 + ? podResult.Data[0].DataPoints : []; + } + + private static string GetBorderClass(double percent) => percent switch + { + >= 90 => "border-danger", + >= 70 => "border-warning", + _ => "border-success" + }; + + private static string GetTextClass(double percent) => percent switch + { + >= 90 => "text-danger", + >= 70 => "text-warning", + _ => "text-success" + }; + + private static string GetBarClass(double percent) => percent switch + { + >= 90 => "bg-danger", + >= 70 => "bg-warning", + _ => "bg-success" + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/ClusterTab.razor b/src/EntKube.Web/Components/Pages/Tenants/ClusterTab.razor new file mode 100644 index 0000000..bd72cb6 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/ClusterTab.razor @@ -0,0 +1,169 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService +@inject VaultService VaultService + +

Kubernetes Clusters

+

Clusters registered in this tenant, each assigned to an environment.

+ +@if (environments is not null && environments.Count > 0) +{ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+} +else +{ +
Create at least one environment before adding clusters.
+} + +@if (!string.IsNullOrEmpty(errorMessage)) +{ +
@errorMessage
+} + +@if (clusters is null) +{ +

Loading...

+} +else if (clusters.Count == 0) +{ +

No clusters registered yet.

+} +else +{ + + + + + + + + + + + @foreach (KubernetesCluster cluster in clusters) + { + + + + + + + + @if (expandedClusterId == cluster.Id) + { + + + + } + + @if (healthClusterId == cluster.Id) + { + + + + } + } + +
NameEnvironmentAPI Server
@cluster.Name@cluster.Environment.Name@cluster.ApiServerUrl + + + +
+ +
+ +
+} + +@code { + [Parameter] public Guid TenantId { get; set; } + + private List? clusters; + private List? environments; + private string newName = ""; + private string newApiUrl = ""; + private Guid selectedEnvironmentId; + private string? errorMessage; + private Guid? expandedClusterId; + private Guid? healthClusterId; + + protected override async Task OnInitializedAsync() + { + await Load(); + environments = await TenantService.GetEnvironmentsAsync(TenantId); + } + + private async Task Load() + { + clusters = await TenantService.GetClustersAsync(TenantId); + } + + private async Task Create() + { + errorMessage = null; + + try + { + await TenantService.CreateClusterAsync(TenantId, selectedEnvironmentId, newName.Trim(), newApiUrl.Trim()); + newName = ""; + newApiUrl = ""; + selectedEnvironmentId = Guid.Empty; + await Load(); + } + catch (DbUpdateException) + { + errorMessage = "A cluster with that name already exists in this tenant."; + } + } + + private void ToggleComponents(Guid clusterId) + { + expandedClusterId = expandedClusterId == clusterId ? null : clusterId; + if (expandedClusterId is not null) + { + healthClusterId = null; + } + } + + private void ToggleHealth(Guid clusterId) + { + healthClusterId = healthClusterId == clusterId ? null : clusterId; + if (healthClusterId is not null) + { + expandedClusterId = null; + } + } + + private async Task Delete(Guid id) + { + await TenantService.DeleteClusterAsync(id); + await Load(); + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/ComponentPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/ComponentPanel.razor new file mode 100644 index 0000000..162fe74 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/ComponentPanel.razor @@ -0,0 +1,293 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject VaultService VaultService + +
+
+ Components + +
+
+
+
+ +
+
+ +
+
+ +
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + + @if (components is null) + { +

Loading...

+ } + else if (components.Count == 0) + { +

No components deployed to this cluster yet.

+ } + else + { + + + + + + + + + + @foreach (ClusterComponent component in components) + { + + + + + + + @if (expandedComponentId == component.Id) + { + + + + } + } + +
NameType
@component.Name@component.ComponentType + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ + @if (componentSecrets is not null && componentSecrets.Count > 0) + { + + + + + + + + + + + @foreach (VaultSecret secret in componentSecrets) + { + + + + + + + + @if (syncConfigSecretId == secret.Id) + { + + + + } + } + +
NameK8s SyncTarget Secret
@secret.Name + @if (secret.SyncToKubernetes) + { + Synced + } + else + { + Off + } + @(secret.KubernetesSecretName ?? "—") + + +
+
+
+ +
+
+ +
+
+ + +
+
+
+ } + else + { +

No secrets for this component.

+ } +
+
+ } +
+
+ +@code { + [Parameter] public Guid ClusterId { get; set; } + [Parameter] public Guid TenantId { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + + private List? components; + private List? componentSecrets; + private string newComponentName = ""; + private string newComponentType = "HelmChart"; + private string? errorMessage; + private Guid? expandedComponentId; + private string newSecretName = ""; + private string newSecretValue = ""; + private Guid? syncConfigSecretId; + private string syncSecretName = ""; + private string syncNamespace = ""; + + protected override async Task OnInitializedAsync() + { + await LoadComponents(); + } + + private async Task LoadComponents() + { + components = await VaultService.GetComponentsAsync(ClusterId); + } + + private async Task AddComponent() + { + errorMessage = null; + + try + { + await VaultService.CreateComponentAsync(ClusterId, newComponentName.Trim(), newComponentType); + newComponentName = ""; + await LoadComponents(); + } + catch (DbUpdateException) + { + errorMessage = "A component with that name already exists on this cluster."; + } + } + + private async Task DeleteComponent(Guid componentId) + { + await VaultService.DeleteComponentAsync(componentId); + + if (expandedComponentId == componentId) + { + expandedComponentId = null; + } + + await LoadComponents(); + } + + private async Task ToggleSecrets(Guid componentId) + { + if (expandedComponentId == componentId) + { + expandedComponentId = null; + componentSecrets = null; + return; + } + + expandedComponentId = componentId; + newSecretName = ""; + newSecretValue = ""; + + // Ensure vault is initialized for this tenant. + await VaultService.InitializeVaultAsync(TenantId); + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private async Task AddSecret(Guid componentId) + { + await VaultService.SetComponentSecretAsync(TenantId, componentId, newSecretName.Trim(), newSecretValue.Trim()); + newSecretName = ""; + newSecretValue = ""; + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private async Task DeleteSecret(Guid secretId, Guid componentId) + { + await VaultService.DeleteSecretAsync(secretId); + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } + + private void ToggleSecretSync(VaultSecret secret) + { + if (secret.SyncToKubernetes) + { + _ = DisableSync(secret); + } + else + { + syncConfigSecretId = secret.Id; + syncSecretName = secret.KubernetesSecretName ?? ""; + syncNamespace = secret.KubernetesNamespace ?? ""; + } + } + + private async Task DisableSync(VaultSecret secret) + { + await VaultService.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: false, secretName: null, ns: null); + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, expandedComponentId!.Value); + } + + private async Task SaveSync(Guid componentId) + { + if (syncConfigSecretId is null) + { + return; + } + + await VaultService.ConfigureKubernetesSyncAsync( + syncConfigSecretId.Value, syncEnabled: true, secretName: syncSecretName.Trim(), ns: syncNamespace.Trim()); + + syncConfigSecretId = null; + componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId); + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor b/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor new file mode 100644 index 0000000..5a656bb --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor @@ -0,0 +1,439 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService +@inject CustomerAccessService CustomerAccessService + +@* =========================================================================== + Three-level drill-down: Customers ▸ Apps ▸ App Detail + The user sees ONE level at a time — no nested panel clutter. + =========================================================================== *@ + +@if (selectedApp is not null) +{ + @* ───────────── Level 3: App Detail ───────────── *@ + +} +else if (selectedCustomer is not null) +{ + @* ───────────── Level 2: Apps for a customer ───────────── *@ + + + +
+ +
+

@selectedCustomer.Name

+ Applications owned by this customer +
+
+ + @* --- Add App --- *@ +
+
+
+ + + +
+ @if (!string.IsNullOrEmpty(appError)) + { +
@appError
+ } +
+
+ + @* --- App cards --- *@ + @if (apps is null) + { +
+
+
+ } + else if (apps.Count == 0) + { +
+ +

No apps yet. Create one above.

+
+ } + else + { +
+ @foreach (Data.App app in apps) + { +
+
+
+
+ +
+
@app.Name
+
+ @if (app.AppEnvironments.Any()) + { + @foreach (AppEnvironment ae in app.AppEnvironments) + { + @ae.Environment.Name + } + } + else + { + No environments linked + } +
+ Created @app.CreatedAt.ToString("MMM d, yyyy") +
+ +
+
+
+
+ } +
+ } + + @* --- Portal Access Management --- *@ +
+
+
+ + Portal Access +
+ Grant users access to this customer's apps via the customer portal. +
+
+ @* --- Grant access form --- *@ +
+ + + +
+ + @if (!string.IsNullOrEmpty(accessError)) + { +
@accessError
+ } + + @if (!string.IsNullOrEmpty(accessSuccess)) + { +
@accessSuccess
+ } + + @* --- Current access list --- *@ + @if (customerAccesses is null) + { +
+
+
+ } + else if (customerAccesses.Count == 0) + { +

No users have portal access to this customer yet.

+ } + else + { + @foreach (CustomerAccess access in customerAccesses) + { +
+
+ + @access.User.Email + @access.Role +
+ +
+ } + } +
+
+} +else +{ + @* ───────────── Level 1: Customer list ───────────── *@ + +
+ +

Customers

+
+

End-clients or accounts served by this tenant. Click a customer to manage their apps.

+ +
+
+
+ + + +
+
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + @if (customers is null) + { +
+
+ Loading customers... +
+ } + else if (customers.Count == 0) + { +
+ +

No customers yet. Create one above to get started.

+
+ } + else + { +
+ @foreach (Customer customer in customers) + { +
+ @if (confirmDeleteId == customer.Id) + { +
+ Delete @customer.Name and all apps? +
+ + +
+
+ } + else + { +
+
+ +
+ @customer.Name +
+ @(appCounts.GetValueOrDefault(customer.Id, 0)) app(s) · Created @customer.CreatedAt.ToString("MMM d, yyyy") +
+
+
+ + +
+
+ } +
+ } +
+ } +} + +@code { + [Parameter] public Guid TenantId { get; set; } + + // --- Level 1: Customer list --- + private List? customers; + private Dictionary appCounts = new(); + private string newName = ""; + private string? errorMessage; + private Guid? confirmDeleteId; + + // --- Level 2: Selected customer → apps --- + private Customer? selectedCustomer; + private List? apps; + private string newAppName = ""; + private string? appError; + + // --- Level 3: Selected app → detail --- + private Data.App? selectedApp; + + // --- Portal access management --- + private List? customerAccesses; + private string grantEmail = ""; + private CustomerAccessRole grantRole = CustomerAccessRole.Viewer; + private string? accessError; + private string? accessSuccess; + + protected override async Task OnInitializedAsync() + { + await LoadCustomers(); + } + + // ──────────────── Level 1 ──────────────── + + private async Task LoadCustomers() + { + customers = await TenantService.GetCustomersAsync(TenantId); + + // Pre-load app counts for the list view. + appCounts = new Dictionary(); + + foreach (Customer customer in customers) + { + List customerApps = await TenantService.GetAppsAsync(customer.Id); + appCounts[customer.Id] = customerApps.Count; + } + } + + private async Task Create() + { + errorMessage = null; + + try + { + await TenantService.CreateCustomerAsync(TenantId, newName.Trim()); + newName = ""; + await LoadCustomers(); + } + catch (DbUpdateException) + { + errorMessage = "A customer with that name already exists."; + } + } + + private async Task Delete(Guid id) + { + confirmDeleteId = null; + await TenantService.DeleteCustomerAsync(id); + await LoadCustomers(); + } + + private async Task OpenCustomer(Customer customer) + { + selectedCustomer = customer; + selectedApp = null; + appError = null; + newAppName = ""; + accessError = null; + accessSuccess = null; + await LoadApps(); + await LoadAccesses(); + } + + private async Task BackToCustomers() + { + selectedCustomer = null; + selectedApp = null; + apps = null; + await LoadCustomers(); + } + + // ──────────────── Level 2 ──────────────── + + private async Task LoadApps() + { + apps = await TenantService.GetAppsAsync(selectedCustomer!.Id); + } + + private async Task CreateApp() + { + appError = null; + + try + { + await TenantService.CreateAppAsync(selectedCustomer!.Id, newAppName.Trim()); + newAppName = ""; + await LoadApps(); + } + catch (DbUpdateException) + { + appError = "An app with that name already exists for this customer."; + } + } + + private void OpenApp(Data.App app) + { + selectedApp = app; + } + + private async Task BackToApps() + { + selectedApp = null; + await LoadApps(); + } + + private async Task AppDeleted() + { + selectedApp = null; + await LoadApps(); + } + + // ──────────────── Portal Access ──────────────── + + private async Task LoadAccesses() + { + customerAccesses = await CustomerAccessService.GetCustomerUsersAsync(selectedCustomer!.Id); + } + + private async Task GrantAccess() + { + accessError = null; + accessSuccess = null; + + // Look up the user by email. + ApplicationUser? user = await TenantService.FindUserByEmailAsync(grantEmail.Trim()); + + if (user is null) + { + accessError = $"No user found with email '{grantEmail}'."; + return; + } + + try + { + await CustomerAccessService.GrantAccessAsync(user.Id, selectedCustomer!.Id, grantRole); + accessSuccess = $"Access granted to {grantEmail}."; + grantEmail = ""; + await LoadAccesses(); + } + catch (Exception) + { + accessError = $"User '{grantEmail}' already has access to this customer."; + } + } + + private async Task RevokeAccess(string userId) + { + await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id); + await LoadAccesses(); + } + + private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch + { + CustomerAccessRole.Admin => "bg-danger", + CustomerAccessRole.Operator => "bg-warning text-dark", + CustomerAccessRole.Viewer => "bg-info", + _ => "bg-secondary" + }; +} \ No newline at end of file diff --git a/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor b/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor new file mode 100644 index 0000000..aed0355 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor @@ -0,0 +1,1338 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject DatabaseService DatabaseService +@inject CnpgService CnpgService +@inject StorageService StorageService + +@* =========================================================================== + Databases Tab — shows CNPG PostgreSQL clusters and MongoDB clusters + discovered across the tenant's Kubernetes clusters. + Requires the respective operators (cloudnative-pg, mongodb-operator) + to be installed on at least one cluster. + =========================================================================== *@ + +@if (loading) +{ +
+
+

Discovering database clusters...

+
+} +else if (operatorStatus is not null && !operatorStatus.CnpgAvailable && !operatorStatus.MongoDbAvailable) +{ + @* No operators installed — show helpful guidance *@ +
+ +
+
No Database Operators Installed
+

+ To use the Databases page, install at least one database operator on a cluster: +

+
    +
  • CloudNativePG — for managed PostgreSQL clusters
  • +
  • MongoDB Community Operator — for managed MongoDB replica sets
  • +
+

+ Go to Environments → Cluster → Components to install an operator. +

+
+
+} +else +{ + @* ── Operator availability badges ── *@ +
+ @if (operatorStatus!.CnpgAvailable) + { + + PostgreSQL (CNPG) + on @operatorStatus.CnpgClusterName + + } + else + { + + PostgreSQL (CNPG) — not installed + + } + + @if (operatorStatus.MongoDbAvailable) + { + + MongoDB + on @operatorStatus.MongoDbClusterName + + } + else + { + + MongoDB — not installed + + } +
+ + @* ── PostgreSQL Clusters ── *@ + @if (operatorStatus.CnpgAvailable) + { +
+
+ PostgreSQL Clusters + @if (cnpgClusters is not null) + { + @cnpgClusters.Count + } +
+ + @if (cnpgClusters is null) + { +
+
+
+ } + else if (cnpgClusters.Count == 0) + { +
+ + No PostgreSQL clusters discovered on Kubernetes. +
+ } + else + { +
+ @foreach (DatabaseClusterInfo dbCluster in cnpgClusters) + { +
+
+
+
+ + @dbCluster.Name +
+ @(GetStatusBadge(dbCluster.Status)) +
+
+
+
+ @dbCluster.EnvironmentName +
+
+ @dbCluster.ClusterName +
+
+ @dbCluster.Namespace +
+
+ @dbCluster.Instances instance(s) +
+ @if (dbCluster.Storage is not null) + { +
+ @dbCluster.Storage +
+ } + @if (dbCluster.Version is not null) + { +
+ @dbCluster.Version +
+ } +
+ + @if (dbCluster.Databases.Count > 0) + { +
+
+ Databases: + @foreach (string dbName in dbCluster.Databases) + { + @dbName + } +
+ } + + @if (dbCluster.PrimaryPod is not null) + { +
+ Primary: @dbCluster.PrimaryPod +
+ } +
+ @if (dbCluster.CreatedAt.HasValue) + { + + } +
+
+ } +
+ } +
+ } + + @* ── Managed CNPG Clusters ── *@ + @if (operatorStatus!.CnpgAvailable) + { +
+
+ Managed PostgreSQL Clusters + @if (managedClusters is not null) + { + @managedClusters.Count + } + +
+ + @* ── Create Cluster Form ── *@ + @if (showCreate) + { +
+
+
New CNPG Cluster
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(createError)) + { +
+ @createError +
+ } +
+
+ } + + @* ── Managed Cluster Cards ── *@ + @if (managedClusters is null) + { +
+
+
+ } + else if (managedClusters.Count == 0 && !showCreate) + { +
+ + No managed clusters yet. Click "Create Cluster" to provision a new PostgreSQL 18 cluster. +
+ } + else + { + @foreach (CnpgCluster mc in managedClusters) + { +
+
+
+ + + @mc.Name + PG @mc.PostgresVersion + @(GetManagedStatusBadge(mc.Status)) + @if (mc.Databases.Count > 0) + { + @mc.Databases.Count db(s) + } +
+
+ + @if (mc.StorageLinkId.HasValue) + { + + + } + + +
+
+ + @* ── Expanded Detail Panel ── *@ + @if (expandedClusterId == mc.Id) + { +
+ @if (loadingDetail) + { +
+
+ Querying cluster status... +
+ } + else if (clusterDetail is not null) + { + @* ── Cluster Overview ── *@ +
+
+
+
Phase
+
+ @if (clusterDetail.Phase.Contains("healthy", StringComparison.OrdinalIgnoreCase)) + { + @clusterDetail.Phase + } + else if (clusterDetail.Phase.Contains("Creating", StringComparison.OrdinalIgnoreCase) + || clusterDetail.Phase.Contains("Setting", StringComparison.OrdinalIgnoreCase)) + { + @clusterDetail.Phase + } + else + { + @clusterDetail.Phase + } +
+
+
+
+
+
Ready
+
@clusterDetail.ReadyInstances / @mc.Instances
+
+
+
+
+
Primary
+
@(clusterDetail.CurrentPrimary ?? "—")
+
+
+
+
+
Info
+
+ @mc.KubernetesCluster.Name + @mc.Namespace +
+
+
+
+ + @* ── Pod Table ── *@ +
Pods
+ @if (clusterDetail.Pods.Count > 0) + { +
+ + + + + + + + + + + + + + @foreach (CnpgPodInfo pod in clusterDetail.Pods) + { + + + + + + + + + + } + +
PodRoleStatusReadyNodeAgeRestarts
@pod.Name + @if (pod.Role == "primary") + { + + Primary + + } + else + { + + Replica + + } + + @if (pod.Status == "Running") + { + Running + } + else if (pod.Status == "Pending") + { + Pending + } + else + { + @pod.Status + } + + @if (pod.Ready) + { + + } + else + { + + } + @(pod.Node ?? "—")@FormatAge(pod.StartTime) + @if (pod.Restarts > 0) + { + @pod.Restarts + } + else + { + 0 + } +
+
+ } + else + { +
+ No pods found. Cluster may still be initializing. +
+ } + + @* ── Databases ── *@ +
+ Databases + @mc.Databases.Count +
+ @if (mc.Databases.Count > 0) + { +
+ + + + + + + + + + + + @foreach (CnpgDatabase cdb in mc.Databases) + { + + + + + + + + } + +
DatabaseOwnerStatusCreated
@cdb.Name@cdb.Owner + @if (cdb.Status == CnpgDatabaseStatus.Ready) + { + Ready + } + else if (cdb.Status == CnpgDatabaseStatus.Creating) + { + Creating + } + else + { + @cdb.Status + } + @cdb.CreatedAt.ToString("yyyy-MM-dd HH:mm") + +
+
+ } + else + { +

No databases created yet. Use the button to add one.

+ } + + @* ── Backups ── *@ +
+ Backups + @clusterDetail.Cluster.Backups.Count +
+ @if (clusterDetail.Cluster.Backups.Count > 0) + { +
+ + + + + + + + + + + + + @foreach (CnpgBackup bk in clusterDetail.Cluster.Backups) + { + + + + + + + + + } + +
NameTypeStatusStartedCompletedSize
@bk.Name + @if (bk.Type == CnpgBackupType.Scheduled) + { + Scheduled + } + else + { + On-Demand + } + + @if (bk.Status == CnpgBackupStatus.Completed) + { + Completed + } + else if (bk.Status == CnpgBackupStatus.Running) + { + Running + } + else + { + Failed + } + @bk.StartedAt.ToString("yyyy-MM-dd HH:mm")@(bk.CompletedAt?.ToString("HH:mm") ?? "—")@(bk.SizeBytes.HasValue ? FormatBytes(bk.SizeBytes.Value) : "—")
+
+ } + else if (mc.StorageLinkId.HasValue) + { +

No backups yet. Click to create one.

+ } + else + { +

Backups are not configured. Assign an S3 bucket to enable backups.

+ } + } + + @if (!string.IsNullOrEmpty(mc.LastError)) + { +
+ @mc.LastError +
+ } +
+ } + else + { + @* ── Collapsed Summary Row ── *@ +
+
+
+ @mc.KubernetesCluster.Name +
+
+ @mc.Namespace +
+
+ @mc.Instances instance(s) +
+
+ @mc.StorageSize +
+ @if (mc.StorageLink is not null) + { +
+ @mc.StorageLink.BucketName +
+ } + @if (mc.Databases.Count > 0) + { +
+ @string.Join(", ", mc.Databases.Select(d => d.Name)) +
+ } +
+
+ } +
+ } + } + + @* ── Add Database Form ── *@ + @if (addDbCluster is not null) + { +
+
+
Add Database to @addDbCluster.Name
+
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(dbError)) + { +
@dbError
+ } +
+
+ } + + @* ── Restore Form ── *@ + @if (restoreSource is not null) + { +
+
+
Point-in-Time Restore from @restoreSource.Name
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(restoreError)) + { +
@restoreError
+ } +
+
+ } + + @* ── Upgrade Form ── *@ + @if (upgradeCluster is not null) + { +
+
+
Upgrade @upgradeCluster.Name (currently PG @upgradeCluster.PostgresVersion)
+
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(upgradeError)) + { +
@upgradeError
+ } +
+
+ } +
+ } + + @* ── MongoDB Clusters ── *@ + @if (operatorStatus.MongoDbAvailable) + { +
+
+ MongoDB Clusters + @if (mongoClusters is not null) + { + @mongoClusters.Count + } +
+ + @if (mongoClusters is null) + { +
+
+
+ } + else if (mongoClusters.Count == 0) + { +
+ + No MongoDB clusters found. Create a MongoDBCommunity resource in your namespace. +
+ } + else + { +
+ @foreach (DatabaseClusterInfo dbCluster in mongoClusters) + { +
+
+
+
+ + @dbCluster.Name +
+ @(GetStatusBadge(dbCluster.Status)) +
+
+
+
+ @dbCluster.EnvironmentName +
+
+ @dbCluster.ClusterName +
+
+ @dbCluster.Namespace +
+
+ @dbCluster.Instances member(s) +
+ @if (dbCluster.Version is not null) + { +
+ v@dbCluster.Version +
+ } +
+ + @if (dbCluster.Databases.Count > 0) + { +
+
+ Databases: + @foreach (string dbName in dbCluster.Databases) + { + @dbName + } +
+ } +
+ @if (dbCluster.CreatedAt.HasValue) + { + + } +
+
+ } +
+ } +
+ } + + @* ── Refresh button ── *@ +
+ +
+} + +@code { + [Parameter] public Guid TenantId { get; set; } + + private bool loading = true; + private bool refreshing; + private DatabaseOperatorStatus? operatorStatus; + private List? cnpgClusters; + private List? mongoClusters; + + // Managed CNPG cluster state + private List? managedClusters; + private List? availableClusters; + private List? storageLinks; + + // Create cluster form + private bool showCreate; + private bool creating; + private string? createError; + private string createName = ""; + private string createNamespace = "databases"; + private Guid createClusterId; + private int createInstances = 3; + private string createStorageSize = "10Gi"; + private Guid createStorageLinkId; + private string createSchedule = ""; + + // Cluster detail panel + private Guid? expandedClusterId; + private CnpgClusterDetail? clusterDetail; + private bool loadingDetail; + + // Delete cluster + private Guid? deletingId; + + // Backup + private Guid? backingUpId; + + // Restore form + private CnpgCluster? restoreSource; + private string restoreName = ""; + private DateTime restoreTargetTime = DateTime.UtcNow.AddHours(-1); + private bool restoring; + private string? restoreError; + + // Upgrade form + private CnpgCluster? upgradeCluster; + private string upgradeVersion = "18"; + private bool upgrading; + private string? upgradeError; + + // Add database form + private CnpgCluster? addDbCluster; + private string newDbName = ""; + private bool addingDb; + private string? dbError; + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + loading = true; + StateHasChanged(); + + operatorStatus = await DatabaseService.GetOperatorStatusAsync(TenantId); + + if (operatorStatus.CnpgAvailable || operatorStatus.MongoDbAvailable) + { + // Load discovery and managed clusters in parallel. + + Task> cnpgTask = operatorStatus.CnpgAvailable + ? DatabaseService.GetCnpgClustersAsync(TenantId) + : Task.FromResult(new List()); + + Task> mongoTask = operatorStatus.MongoDbAvailable + ? DatabaseService.GetMongoDbClustersAsync(TenantId) + : Task.FromResult(new List()); + + Task> managedTask = operatorStatus.CnpgAvailable + ? CnpgService.GetClustersAsync(TenantId) + : Task.FromResult(new List()); + + await Task.WhenAll(cnpgTask, mongoTask, managedTask); + cnpgClusters = cnpgTask.Result; + mongoClusters = mongoTask.Result; + managedClusters = managedTask.Result; + } + + loading = false; + } + + private async Task RefreshAll() + { + refreshing = true; + StateHasChanged(); + + try + { + await LoadData(); + } + finally + { + refreshing = false; + } + } + + // ──────── Cluster Detail Panel ──────── + + private async Task ToggleClusterDetail(CnpgCluster cluster) + { + // If already expanded, collapse it. + + if (expandedClusterId == cluster.Id) + { + expandedClusterId = null; + clusterDetail = null; + return; + } + + // Expand and fetch live pod/status info from K8s. + + expandedClusterId = cluster.Id; + clusterDetail = null; + loadingDetail = true; + StateHasChanged(); + + try + { + clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, cluster.Id); + } + catch (Exception) + { + clusterDetail = new CnpgClusterDetail + { + Cluster = cluster, + Phase = "Error fetching status" + }; + } + finally + { + loadingDetail = false; + } + } + + private static string FormatAge(DateTime? startTime) + { + if (startTime is null) + { + return "—"; + } + + TimeSpan age = DateTime.UtcNow - startTime.Value.ToUniversalTime(); + + if (age.TotalDays >= 1) + { + return $"{(int)age.TotalDays}d {age.Hours}h"; + } + + if (age.TotalHours >= 1) + { + return $"{(int)age.TotalHours}h {age.Minutes}m"; + } + + return $"{(int)age.TotalMinutes}m"; + } + + private static string FormatBytes(long bytes) + { + if (bytes >= 1_073_741_824) + { + return $"{bytes / 1_073_741_824.0:F1} GB"; + } + + if (bytes >= 1_048_576) + { + return $"{bytes / 1_048_576.0:F1} MB"; + } + + if (bytes >= 1024) + { + return $"{bytes / 1024.0:F0} KB"; + } + + return $"{bytes} B"; + } + + // ──────── Create Cluster ──────── + + private async Task ShowCreateForm() + { + showCreate = true; + createError = null; + + // Load available K8s clusters (those with CNPG installed) and S3 storage links. + + availableClusters = await DatabaseService.GetCnpgEnabledClustersAsync(TenantId); + storageLinks = await StorageService.GetStorageLinksAsync(TenantId); + } + + private void CancelCreate() + { + showCreate = false; + createError = null; + createName = ""; + createNamespace = "databases"; + createClusterId = Guid.Empty; + createInstances = 3; + createStorageSize = "10Gi"; + createStorageLinkId = Guid.Empty; + createSchedule = ""; + } + + private async Task CreateCluster() + { + if (string.IsNullOrWhiteSpace(createName) || createClusterId == Guid.Empty) + { + createError = "Name and target cluster are required."; + return; + } + + creating = true; + createError = null; + StateHasChanged(); + + try + { + Guid? storageLinkId = createStorageLinkId != Guid.Empty ? createStorageLinkId : null; + string? schedule = !string.IsNullOrWhiteSpace(createSchedule) ? createSchedule : null; + + await CnpgService.CreateClusterAsync( + TenantId, createClusterId, createName.Trim(), createNamespace.Trim(), + createInstances, createStorageSize.Trim(), storageLinkId, schedule); + + CancelCreate(); + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + createError = ex.Message; + } + finally + { + creating = false; + } + } + + // ──────── Delete Cluster ──────── + + private async Task DeleteCluster(Guid cnpgClusterId) + { + deletingId = cnpgClusterId; + StateHasChanged(); + + try + { + await CnpgService.DeleteClusterAsync(TenantId, cnpgClusterId); + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + createError = $"Delete failed: {ex.Message}"; + } + finally + { + deletingId = null; + } + } + + // ──────── Backup ──────── + + private async Task TriggerBackup(Guid cnpgClusterId) + { + backingUpId = cnpgClusterId; + StateHasChanged(); + + try + { + await CnpgService.BackupAsync(TenantId, cnpgClusterId); + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + createError = $"Backup failed: {ex.Message}"; + } + finally + { + backingUpId = null; + } + } + + // ──────── Restore ──────── + + private void ShowRestore(CnpgCluster source) + { + restoreSource = source; + restoreName = $"{source.Name}-restored"; + restoreTargetTime = DateTime.UtcNow.AddHours(-1); + restoreError = null; + } + + private async Task ExecuteRestore() + { + if (string.IsNullOrWhiteSpace(restoreName)) + { + restoreError = "New cluster name is required."; + return; + } + + restoring = true; + restoreError = null; + StateHasChanged(); + + try + { + await CnpgService.RestoreAsync( + TenantId, restoreSource!.Id, restoreName.Trim(), restoreTargetTime); + + restoreSource = null; + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + restoreError = ex.Message; + } + finally + { + restoring = false; + } + } + + // ──────── Upgrade ──────── + + private void ShowUpgrade(CnpgCluster cluster) + { + upgradeCluster = cluster; + upgradeVersion = "18"; + upgradeError = null; + } + + private async Task ExecuteUpgrade() + { + upgrading = true; + upgradeError = null; + StateHasChanged(); + + try + { + // Determine whether this is a minor (rolling) or major (restore-based) upgrade. + // Minor: same major version, zero-downtime rolling update. + // Major: restore to new cluster, swap name, remove old (~10-30s downtime). + + int currentMajor = int.Parse(upgradeCluster!.PostgresVersion.Split('.')[0].Split('-')[0]); + int targetMajor = int.Parse(upgradeVersion.Split('.')[0].Split('-')[0]); + + if (targetMajor != currentMajor) + { + await CnpgService.MajorUpgradeAsync(TenantId, upgradeCluster.Id, upgradeVersion); + } + else + { + await CnpgService.UpgradeClusterAsync(TenantId, upgradeCluster.Id, upgradeVersion); + } + + upgradeCluster = null; + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + upgradeError = ex.Message; + } + finally + { + upgrading = false; + } + } + + // ──────── Database Management ──────── + + private void ShowAddDatabase(CnpgCluster cluster) + { + addDbCluster = cluster; + newDbName = ""; + dbError = null; + } + + private async Task AddDatabase() + { + if (string.IsNullOrWhiteSpace(newDbName)) + { + dbError = "Database name is required."; + return; + } + + addingDb = true; + dbError = null; + StateHasChanged(); + + try + { + await CnpgService.CreateDatabaseAsync(TenantId, addDbCluster!.Id, newDbName.Trim()); + addDbCluster = null; + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + dbError = ex.Message; + } + finally + { + addingDb = false; + } + } + + private async Task DeleteDatabase(Guid cnpgClusterId, Guid databaseId) + { + try + { + await CnpgService.DeleteDatabaseAsync(TenantId, cnpgClusterId, databaseId); + managedClusters = await CnpgService.GetClustersAsync(TenantId); + } + catch (Exception ex) + { + dbError = $"Failed to delete database: {ex.Message}"; + } + } + + // ──────── Status Helpers ──────── + + private static RenderFragment GetStatusBadge(string status) => status.ToLowerInvariant() switch + { + "cluster in healthy state" or "healthy" or "running" => __builder => + { + Healthy + }, + "setting up primary" or "creating primary" or "pending" => __builder => + { + Provisioning + }, + "failed" or "error" => __builder => + { + Failed + }, + _ => __builder => + { + @status + } + }; + + private static RenderFragment GetManagedStatusBadge(CnpgClusterStatus status) => status switch + { + CnpgClusterStatus.Running => __builder => + { + Running + }, + CnpgClusterStatus.Creating => __builder => + { + Creating + }, + CnpgClusterStatus.Upgrading => __builder => + { + Upgrading + }, + CnpgClusterStatus.Restoring => __builder => + { + Restoring + }, + CnpgClusterStatus.Failed => __builder => + { + Failed + }, + CnpgClusterStatus.Deleting => __builder => + { + Deleting + }, + _ => __builder => + { + @status + } + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/EnvironmentTab.razor b/src/EntKube.Web/Components/Pages/Tenants/EnvironmentTab.razor new file mode 100644 index 0000000..4b3754c --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/EnvironmentTab.razor @@ -0,0 +1,447 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService +@inject VaultService VaultService + +@* =========================================================================== + Three-level drill-down: Environments ▸ Clusters ▸ Cluster Detail + The user sees ONE level at a time — no nested accordion clutter. + =========================================================================== *@ + +@if (selectedCluster is not null) +{ + @* ───────────── Level 3: Cluster Detail ───────────── *@ + +} +else if (selectedEnv is not null) +{ + @* ───────────── Level 2: Clusters in an environment ───────────── *@ + + @* Breadcrumb back to environments *@ + + +
+ +
+

@selectedEnv.Name

+ Clusters registered in this environment +
+
+ + @* --- Add Cluster (kubeconfig) --- *@ + @if (showAddCluster) + { +
+
+ Register a Cluster + +
+
+ @if (parsedContexts is null || parsedContexts.Count == 0) + { +

Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.

+ + + +
+ + +
+ } + else + { +

Found @parsedContexts.Count context(s). Select one to register:

+ +
+ @foreach (KubeconfigContext ctx in parsedContexts) + { + + } +
+ +
+
+ + +
+
+ +
+
+ } + + @if (!string.IsNullOrEmpty(clusterError)) + { +
+ @clusterError +
+ } +
+
+ } + else + { + + } + + @* --- Cluster Cards --- *@ + @if (envClusters is null) + { +
+
+
+ } + else if (envClusters.Count == 0) + { +
+ +

No clusters registered yet.

+
+ } + else + { +
+ @foreach (KubernetesCluster cluster in envClusters) + { +
+
+
+
+ +
+
@cluster.Name
+ @if (!string.IsNullOrEmpty(cluster.ContextName)) + { +
+ @cluster.ContextName +
+ } + + @cluster.ApiServerUrl + +
+ +
+
+
+
+ } +
+ } +} +else +{ + @* ───────────── Level 1: Environment list ───────────── *@ + +
+ +

Environments

+
+

Deployment stages for this tenant. Click an environment to manage its clusters.

+ +
+
+
+ + + +
+
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + @if (environments is null) + { +
+
+ Loading environments... +
+ } + else if (environments.Count == 0) + { +
+ +

No environments yet. Create one above to get started.

+
+ } + else + { +
+ @foreach (Data.Environment env in environments) + { +
+ @if (confirmDeleteEnvId == env.Id) + { +
+ Delete @env.Name and all its clusters? +
+ + +
+
+ } + else + { +
+
+ +
+ @env.Name +
+ @(clusterCounts.GetValueOrDefault(env.Id, 0)) cluster(s) +
+
+
+ + +
+
+ } +
+ } +
+ } +} + +@code { + [Parameter] public Guid TenantId { get; set; } + + // --- Level 1: Environment list --- + private List? environments; + private Dictionary clusterCounts = new(); + private string newName = ""; + private string? errorMessage; + private Guid? confirmDeleteEnvId; + + // --- Level 2: Selected environment → clusters --- + private Data.Environment? selectedEnv; + private List? envClusters; + private bool showAddCluster; + + // --- Level 3: Selected cluster → detail --- + private KubernetesCluster? selectedCluster; + + // --- Kubeconfig parsing --- + private string kubeconfigInput = ""; + private List? parsedContexts; + private string? selectedContext; + private string? selectedServerUrl; + private string newClusterName = ""; + private string? clusterError; + + protected override async Task OnInitializedAsync() + { + await LoadEnvironments(); + } + + // ──────────────── Level 1 ──────────────── + + private async Task LoadEnvironments() + { + environments = await TenantService.GetEnvironmentsAsync(TenantId); + + // Pre-load cluster counts so the list shows "N cluster(s)" without expanding. + List allClusters = await TenantService.GetClustersAsync(TenantId); + clusterCounts = allClusters.GroupBy(c => c.EnvironmentId).ToDictionary(g => g.Key, g => g.Count()); + } + + private async Task Create() + { + errorMessage = null; + + try + { + await TenantService.CreateEnvironmentAsync(TenantId, newName.Trim()); + newName = ""; + await LoadEnvironments(); + } + catch (DbUpdateException) + { + errorMessage = "An environment with that name already exists."; + } + } + + private async Task Delete(Guid id) + { + confirmDeleteEnvId = null; + await TenantService.DeleteEnvironmentAsync(id); + await LoadEnvironments(); + } + + private async Task OpenEnvironment(Data.Environment env) + { + selectedEnv = env; + selectedCluster = null; + showAddCluster = false; + ResetClusterForm(); + await LoadClusters(); + } + + private async Task BackToEnvironments() + { + selectedEnv = null; + selectedCluster = null; + envClusters = null; + await LoadEnvironments(); + } + + // ──────────────── Level 2 ──────────────── + + private async Task LoadClusters() + { + List allClusters = await TenantService.GetClustersAsync(TenantId); + envClusters = allClusters.Where(c => c.EnvironmentId == selectedEnv!.Id).ToList(); + } + + private void OpenCluster(KubernetesCluster cluster) + { + selectedCluster = cluster; + } + + private async Task BackToClusters() + { + selectedCluster = null; + await LoadClusters(); + } + + private async Task ClusterDeleted() + { + selectedCluster = null; + await LoadClusters(); + } + + // --- Kubeconfig --- + + private void ParseKubeconfig() + { + clusterError = null; + parsedContexts = KubeconfigParser.ParseContexts(kubeconfigInput); + + if (parsedContexts.Count == 0) + { + clusterError = "No valid contexts found in the kubeconfig. Check the YAML format."; + return; + } + + KubeconfigContext? defaultCtx = parsedContexts.FirstOrDefault(c => c.IsCurrent) + ?? parsedContexts.First(); + + SelectContext(defaultCtx); + } + + private async Task HandleFileUpload(InputFileChangeEventArgs e) + { + IBrowserFile file = e.File; + + if (file.Size > 1_048_576) + { + clusterError = "File too large. Kubeconfig files should be under 1 MB."; + return; + } + + using StreamReader reader = new(file.OpenReadStream(maxAllowedSize: 1_048_576)); + kubeconfigInput = await reader.ReadToEndAsync(); + ParseKubeconfig(); + } + + private void SelectContext(KubeconfigContext ctx) + { + selectedContext = ctx.Name; + selectedServerUrl = ctx.ClusterServer; + + if (string.IsNullOrWhiteSpace(newClusterName)) + { + newClusterName = ctx.Name; + } + } + + private void ResetClusterForm() + { + kubeconfigInput = ""; + parsedContexts = null; + selectedContext = null; + selectedServerUrl = null; + newClusterName = ""; + clusterError = null; + } + + private async Task CreateCluster() + { + clusterError = null; + + if (string.IsNullOrWhiteSpace(selectedServerUrl)) + { + clusterError = "No context selected."; + return; + } + + try + { + await TenantService.CreateClusterAsync( + TenantId, selectedEnv!.Id, newClusterName.Trim(), selectedServerUrl, + contextName: selectedContext, kubeconfig: kubeconfigInput); + + showAddCluster = false; + ResetClusterForm(); + await LoadClusters(); + } + catch (DbUpdateException) + { + clusterError = "A cluster with that name already exists."; + } + } +} \ No newline at end of file diff --git a/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor b/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor new file mode 100644 index 0000000..bf9f3a9 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor @@ -0,0 +1,134 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject TenantService TenantService + +
+ +

Groups

+
+

Organize users within this tenant into logical groups for access control and team management.

+ +
+
+
+ + + +
+
+
+ +@if (!string.IsNullOrEmpty(errorMessage)) +{ + +} + +@if (groups is null) +{ +
+
+ Loading groups... +
+} +else if (groups.Count == 0) +{ +
+ +

No groups yet. Create one above to get started.

+
+} +else +{ +
+ @foreach (Group group in groups) + { +
+
+
+
+
+
+ @group.Name +
+
+ + @group.Memberships.Count member(s) + + + @group.CreatedAt.ToString("MMM d, yyyy") + +
+
+ +
+
+ + @if (confirmDeleteId == group.Id) + { + + } +
+
+ } +
+} + +@code { + [Parameter] public Guid TenantId { get; set; } + + private List? groups; + private string newName = ""; + private string? errorMessage; + private Guid? confirmDeleteId; + + protected override async Task OnInitializedAsync() + { + await Load(); + } + + private async Task Load() + { + groups = await TenantService.GetGroupsAsync(TenantId); + } + + private async Task Create() + { + errorMessage = null; + + try + { + await TenantService.CreateGroupAsync(TenantId, newName.Trim()); + newName = ""; + await Load(); + } + catch (DbUpdateException) + { + errorMessage = "A group with that name already exists."; + } + } + + private async Task Delete(Guid id) + { + confirmDeleteId = null; + await TenantService.DeleteGroupAsync(id); + await Load(); + } +} \ No newline at end of file diff --git a/src/EntKube.Web/Components/Pages/Tenants/MetricChart.razor b/src/EntKube.Web/Components/Pages/Tenants/MetricChart.razor new file mode 100644 index 0000000..de8ffa0 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/MetricChart.razor @@ -0,0 +1,159 @@ +@using EntKube.Web.Services + +@* ═══════════════════════════════════════════════════════════════════════════ + MetricChart — renders a time-series as an SVG area chart with gradient fill. + Purely server-rendered, no JS dependencies. Responsive via viewBox. + ═══════════════════════════════════════════════════════════════════════════ *@ + +
+ @if (!string.IsNullOrEmpty(Title)) + { +
+ @Title + @if (DataPoints.Count > 0) + { + @CurrentValue + } +
+ } + + @if (DataPoints.Count >= 2) + { + + + + + + + + + @* Area fill below the line *@ + + + @* The line itself *@ + + + } + else + { +
+ Insufficient data +
+ } +
+ +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public List DataPoints { get; set; } = []; + [Parameter] public string Color { get; set; } = "#0d6efd"; + [Parameter] public string? Unit { get; set; } + [Parameter] public int HeightPx { get; set; } = 60; + [Parameter] public double? MaxValue { get; set; } + + private const int Width = 300; + private const int Height = 80; + private const int Padding = 2; + + private string GradientId => $"grad-{Title?.GetHashCode():x}"; + + private string CurrentValue + { + get + { + if (DataPoints.Count == 0) + { + return ""; + } + + double last = DataPoints[^1].Value; + string formatted = last >= 100 ? last.ToString("F0") : last.ToString("F1"); + return Unit is not null ? $"{formatted}{Unit}" : formatted; + } + } + + private string LinePath + { + get + { + if (DataPoints.Count < 2) + { + return ""; + } + + double minVal = 0; + double maxVal = MaxValue ?? DataPoints.Max(d => d.Value); + if (maxVal <= minVal) + { + maxVal = minVal + 1; + } + + double xStep = (double)(Width - 2 * Padding) / (DataPoints.Count - 1); + + List points = []; + for (int i = 0; i < DataPoints.Count; i++) + { + double x = Padding + i * xStep; + double y = Height - Padding - ((DataPoints[i].Value - minVal) / (maxVal - minVal) * (Height - 2 * Padding)); + points.Add($"{x:F1},{y:F1}"); + } + + return string.Join(" ", points); + } + } + + private string AreaPath + { + get + { + if (DataPoints.Count < 2) + { + return ""; + } + + double minVal = 0; + double maxVal = MaxValue ?? DataPoints.Max(d => d.Value); + if (maxVal <= minVal) + { + maxVal = minVal + 1; + } + + double xStep = (double)(Width - 2 * Padding) / (DataPoints.Count - 1); + + // Start at the bottom-left corner. + List pathParts = [$"M {Padding:F1},{Height - Padding:F1}"]; + + // Line to first data point. + for (int i = 0; i < DataPoints.Count; i++) + { + double x = Padding + i * xStep; + double y = Height - Padding - ((DataPoints[i].Value - minVal) / (maxVal - minVal) * (Height - 2 * Padding)); + pathParts.Add($"L {x:F1},{y:F1}"); + } + + // Close back to bottom-right. + double lastX = Padding + (DataPoints.Count - 1) * xStep; + pathParts.Add($"L {lastX:F1},{Height - Padding:F1}"); + pathParts.Add("Z"); + + return string.Join(" ", pathParts); + } + } + + private string GetValueColorClass() + { + if (DataPoints.Count == 0) + { + return ""; + } + + double last = DataPoints[^1].Value; + return last switch + { + >= 90 => "text-danger", + >= 70 => "text-warning", + _ => "text-success" + }; + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor b/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor new file mode 100644 index 0000000..e60b9b8 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor @@ -0,0 +1,1191 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject StorageService StorageService + +@* =========================================================================== + Storage Tab — shows MinIO instances discovered on clusters, plus external + storage links (AWS S3, Azure Storage, Cleura S3) registered by the tenant. + MinIO requires the minio component to be installed on at least one cluster. + External providers are managed as vault-backed links. + =========================================================================== *@ + +@if (loading) +{ +
+
+

Discovering storage resources...

+
+} +else +{ + @* ── MinIO Section ── *@ +
+
+ MinIO (Cluster Object Storage) + @if (minioAvailable) + { + Installed + } + else + { + Not Installed + } +
+ + @if (!minioAvailable) + { +
+ +
+

MinIO is not installed on any cluster.

+

+ Go to Environments → Cluster → Components to install MinIO. +

+
+
+ } + else if (minioInstances.Count == 0) + { +
+ + MinIO is installed but no Tenant resources were found. Create a MinIO Tenant CR in your cluster. +
+ } + else + { +
+ @foreach (MinioBucketInfo instance in minioInstances) + { +
+
+
+
+ + @instance.Name +
+ @(GetMinioStatusBadge(instance.Status)) +
+
+
+
+ @instance.EnvironmentName +
+
+ @instance.ClusterName +
+
+ @instance.Namespace +
+ @if (instance.Storage is not null) + { +
+ @instance.Storage +
+ } + @if (instance.Endpoint is not null) + { +
+ @instance.Endpoint +
+ } +
+
+ @if (instance.CreatedAt.HasValue) + { + + } +
+
+ } +
+ } +
+ + @* ── External Storage Links ── *@ +
+
+
+ External Storage Links + @if (storageLinks.Count > 0) + { + @storageLinks.Count + } +
+ +
+ + @if (!string.IsNullOrEmpty(deleteError)) + { +
+ @deleteError + +
+ } + + @if (storageLinks.Count == 0 && !showForm) + { +
+ + No external storage links configured. Add an AWS S3 bucket, Azure Storage Account, or Cleura S3 bucket. +
+ } + else + { + @* Group by provider *@ + @foreach (IGrouping group in storageLinks.GroupBy(s => s.Provider)) + { +
+ @GetProviderLabel(group.Key) +
+
+ @foreach (StorageLink link in group) + { +
+
+
+
+ + @link.Name +
+
+ @if (link.Provider == StorageProvider.CleuraS3) + { + + + + } + else + { + + } +
+
+
+
+
+ @link.Environment.Name +
+ @if (link.BucketName is not null) + { +
+ @link.BucketName +
+ } + @if (link.Region is not null) + { +
+ @link.Region +
+ } + @if (link.Endpoint is not null) + { +
+ @link.Endpoint +
+ } + @if (link.Notes is not null) + { +
+ @link.Notes +
+ } +
+
+ +
+
+ } +
+ } + } +
+ + @* ── Bucket Management Panel ── *@ + @if (managedLink is not null) + { +
+
+
+ Manage Bucket: @managedLink.BucketName +
+ +
+
+ @if (!string.IsNullOrEmpty(manageError)) + { +
+ @manageError +
+ } + + @if (!string.IsNullOrEmpty(manageSuccess)) + { +
+ @manageSuccess +
+ } + + @* CORS Configuration *@ +
+
CORS Configuration
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + @* Bucket Policy *@ +
+
Bucket Policy (JSON)
+
+ +
+
+ + + +
+
+
+
+ } + + @* ── Add Storage Link Form ── *@ + @if (showForm) + { +
+
+
Add External Storage Link
+
+
+
+
+ + +
+
+ + +
+ @if (formProvider == StorageProvider.CleuraS3) + { +
+ + @if (openStackConnections.Count == 0) + { +
+ No OpenStack connections configured. Add one below first. +
+ } + else + { + + } +
+ } +
+ + +
+
+ + +
+ @if (formProvider != StorageProvider.CleuraS3) + { +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ } + else + { +
+
+ + S3 endpoint and credentials will be auto-generated via the OpenStack connection. + The bucket will be created on save. +
+
+ } +
+ + +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(formError)) + { +
+ @formError +
+ } +
+
+ } + + @* ── OpenStack Connections (required for Cleura S3) ── *@ +
+
+
+ OpenStack Connections + @if (openStackConnections.Count > 0) + { + @openStackConnections.Count + } +
+ +
+ +

+ OpenStack connections are required for managing Cleura S3 buckets and credentials. +

+ + @if (openStackConnections.Count == 0 && !showOsForm) + { +
+ + No OpenStack connections configured. Add one to enable Cleura S3 storage management. +
+ } + else + { +
+ @foreach (OpenStackConnection conn in openStackConnections) + { +
+
+
+
+ + @conn.Name +
+ +
+
+
+ @if (conn.Region is not null) + { +
+ @conn.Region +
+ } + @if (conn.ProjectName is not null) + { +
+ @conn.ProjectName +
+ } + @if (conn.Username is not null) + { +
+ @conn.Username +
+ } +
+ @conn.AuthUrl +
+
+
+ +
+
+ } +
+ } + + @if (showOsForm) + { +
+
+
Add OpenStack Connection
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ } +
+ + @* ── Refresh button ── *@ +
+ +
+} + +@code { + [Parameter] public Guid TenantId { get; set; } + + private bool loading = true; + private bool refreshing; + private bool showForm; + private bool saving; + private bool showOsForm; + private bool savingOs; + private string? formError; + + // MinIO discovery + private bool minioAvailable; + private List minioInstances = []; + + // External storage links + private List storageLinks = []; + private List environments = []; + + // OpenStack connections + private List openStackConnections = []; + + // Storage link form fields + private StorageProvider formProvider = StorageProvider.AwsS3; + private Guid formEnvironmentId; + private Guid formOpenStackConnectionId; + private string formName = ""; + private string formBucket = ""; + private string formEndpoint = ""; + private string formRegion = ""; + private string formAccessKey = ""; + private string formSecretKey = ""; + private string formNotes = ""; + + // OpenStack form fields + private string osFormName = ""; + private string osFormAuthUrl = ""; + private string osFormRegion = ""; + private string osFormProjectName = ""; + private string osFormProjectId = ""; + private string osFormUserDomain = ""; + private string osFormProjectDomain = ""; + private string osFormUsername = ""; + private string osFormPassword = ""; + + // Bucket management state + private StorageLink? managedLink; + private Guid? rotatingLinkId; + private Guid? deletingLinkId; + private string? deleteError; + private bool manageSaving; + private string? manageError; + private string? manageSuccess; + private string corsOrigins = ""; + private string corsMethods = "GET, PUT, POST, DELETE"; + private string corsHeaders = "*"; + private int corsMaxAge = 3600; + private string bucketPolicyJson = ""; + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + loading = true; + StateHasChanged(); + + // Load environments and OpenStack connections for form dropdowns. + + environments = await StorageService.GetEnvironmentsAsync(TenantId); + openStackConnections = await StorageService.GetOpenStackConnectionsAsync(TenantId); + + if (environments.Count > 0 && formEnvironmentId == Guid.Empty) + { + formEnvironmentId = environments[0].Id; + } + + // Check MinIO availability and discover instances in parallel with loading links. + + Task minioTask = StorageService.IsMinioAvailableAsync(TenantId); + Task> linksTask = StorageService.GetStorageLinksAsync(TenantId); + + await Task.WhenAll(minioTask, linksTask); + + minioAvailable = minioTask.Result; + storageLinks = linksTask.Result; + + if (minioAvailable) + { + minioInstances = await StorageService.GetMinioInstancesAsync(TenantId); + } + + loading = false; + } + + // ── Storage Link Form ── + + private void ShowAddForm() + { + showForm = true; + } + + private void CancelForm() + { + showForm = false; + ResetForm(); + } + + private async Task SaveLink() + { + if (string.IsNullOrWhiteSpace(formName)) + { + return; + } + + // Cleura S3 requires an OpenStack connection and bucket name. + if (formProvider == StorageProvider.CleuraS3 && formOpenStackConnectionId == Guid.Empty) + { + return; + } + + if (formProvider == StorageProvider.CleuraS3 && string.IsNullOrWhiteSpace(formBucket)) + { + formError = "Bucket name is required."; + return; + } + + saving = true; + formError = null; + StateHasChanged(); + + try + { + if (formProvider == StorageProvider.CleuraS3) + { + // Provision the bucket via OpenStack (Keystone auth → EC2 creds → S3 create). + + await StorageService.ProvisionCleuraS3BucketAsync( + TenantId, + formEnvironmentId, + formOpenStackConnectionId, + formBucket.Trim(), + formName.Trim(), + string.IsNullOrWhiteSpace(formNotes) ? null : formNotes.Trim()); + } + else + { + // Manual registration for AWS/Azure. + + await StorageService.CreateStorageLinkAsync( + TenantId, + formEnvironmentId, + formProvider, + formName.Trim(), + string.IsNullOrWhiteSpace(formEndpoint) ? null : formEndpoint.Trim(), + string.IsNullOrWhiteSpace(formBucket) ? null : formBucket.Trim(), + string.IsNullOrWhiteSpace(formRegion) ? null : formRegion.Trim(), + string.IsNullOrWhiteSpace(formAccessKey) ? null : formAccessKey.Trim(), + string.IsNullOrWhiteSpace(formSecretKey) ? null : formSecretKey.Trim(), + string.IsNullOrWhiteSpace(formNotes) ? null : formNotes.Trim()); + } + + showForm = false; + ResetForm(); + storageLinks = await StorageService.GetStorageLinksAsync(TenantId); + } + catch (Exception ex) + { + formError = ex.Message; + } + finally + { + saving = false; + } + } + + private async Task DeleteLink(Guid linkId) + { + await StorageService.DeleteStorageLinkAsync(TenantId, linkId); + storageLinks = await StorageService.GetStorageLinksAsync(TenantId); + } + + // ── Cleura S3 Bucket Management ── + + private async Task RotateCredentials(Guid linkId) + { + rotatingLinkId = linkId; + StateHasChanged(); + + try + { + await StorageService.RotateCleuraS3CredentialsAsync(TenantId, linkId); + manageSuccess = "EC2 credentials rotated successfully."; + manageError = null; + } + catch (Exception ex) + { + manageError = $"Credential rotation failed: {ex.Message}"; + manageSuccess = null; + } + finally + { + rotatingLinkId = null; + } + } + + private async Task DeleteCleuraS3Bucket(Guid linkId) + { + deletingLinkId = linkId; + deleteError = null; + StateHasChanged(); + + try + { + await StorageService.DeleteCleuraS3BucketAsync(TenantId, linkId); + storageLinks = await StorageService.GetStorageLinksAsync(TenantId); + CloseManageBucket(); + } + catch (Exception ex) + { + deleteError = $"Bucket deletion failed: {ex.Message}"; + } + finally + { + deletingLinkId = null; + } + } + + private async Task ShowManageBucket(StorageLink link) + { + managedLink = link; + manageError = null; + manageSuccess = null; + corsOrigins = ""; + corsMethods = "GET, PUT, POST, DELETE"; + corsHeaders = "*"; + corsMaxAge = 3600; + bucketPolicyJson = ""; + + // Load existing CORS and policy in parallel. + + await Task.WhenAll(LoadCors(), LoadPolicy()); + } + + private void CloseManageBucket() + { + managedLink = null; + manageError = null; + manageSuccess = null; + } + + private async Task LoadCors() + { + if (managedLink is null) + { + return; + } + + try + { + List? rules = await StorageService.GetBucketCorsAsync(TenantId, managedLink.Id); + + if (rules is not null && rules.Count > 0) + { + S3CorsRule firstRule = rules[0]; + corsOrigins = string.Join("\n", firstRule.AllowedOrigins); + corsMethods = string.Join(", ", firstRule.AllowedMethods); + corsHeaders = string.Join(", ", firstRule.AllowedHeaders); + corsMaxAge = firstRule.MaxAgeSeconds; + } + else + { + corsOrigins = ""; + corsMethods = "GET, PUT, POST, DELETE"; + corsHeaders = "*"; + corsMaxAge = 3600; + } + } + catch (Exception ex) + { + manageError = $"Failed to load CORS: {ex.Message}"; + } + } + + private async Task SaveCors() + { + if (managedLink is null) + { + return; + } + + manageSaving = true; + manageError = null; + manageSuccess = null; + StateHasChanged(); + + try + { + List origins = corsOrigins + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + List methods = corsMethods + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + List headers = corsHeaders + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + List rules = + [ + new() + { + AllowedOrigins = origins, + AllowedMethods = methods, + AllowedHeaders = headers, + MaxAgeSeconds = corsMaxAge + } + ]; + + await StorageService.SetBucketCorsAsync(TenantId, managedLink.Id, rules); + manageSuccess = "CORS configuration saved."; + } + catch (Exception ex) + { + manageError = $"Failed to save CORS: {ex.Message}"; + } + finally + { + manageSaving = false; + } + } + + private async Task RemoveCors() + { + if (managedLink is null) + { + return; + } + + manageSaving = true; + manageError = null; + manageSuccess = null; + StateHasChanged(); + + try + { + await StorageService.SetBucketCorsAsync(TenantId, managedLink.Id, []); + corsOrigins = ""; + corsMethods = "GET, PUT, POST, DELETE"; + corsHeaders = "*"; + corsMaxAge = 3600; + manageSuccess = "CORS configuration removed."; + } + catch (Exception ex) + { + manageError = $"Failed to remove CORS: {ex.Message}"; + } + finally + { + manageSaving = false; + } + } + + private async Task LoadPolicy() + { + if (managedLink is null) + { + return; + } + + try + { + string? policy = await StorageService.GetBucketPolicyAsync(TenantId, managedLink.Id); + bucketPolicyJson = policy ?? ""; + } + catch (Exception ex) + { + manageError = $"Failed to load policy: {ex.Message}"; + } + } + + private async Task SavePolicy() + { + if (managedLink is null) + { + return; + } + + manageSaving = true; + manageError = null; + manageSuccess = null; + StateHasChanged(); + + try + { + string? policy = string.IsNullOrWhiteSpace(bucketPolicyJson) ? null : bucketPolicyJson.Trim(); + await StorageService.SetBucketPolicyAsync(TenantId, managedLink.Id, policy); + manageSuccess = "Bucket policy saved."; + } + catch (Exception ex) + { + manageError = $"Failed to save policy: {ex.Message}"; + } + finally + { + manageSaving = false; + } + } + + private async Task RemovePolicy() + { + if (managedLink is null) + { + return; + } + + manageSaving = true; + manageError = null; + manageSuccess = null; + StateHasChanged(); + + try + { + await StorageService.SetBucketPolicyAsync(TenantId, managedLink.Id, null); + bucketPolicyJson = ""; + manageSuccess = "Bucket policy removed."; + } + catch (Exception ex) + { + manageError = $"Failed to remove policy: {ex.Message}"; + } + finally + { + manageSaving = false; + } + } + + // ── OpenStack Connection Form ── + + private void ShowOpenStackForm() + { + showOsForm = true; + } + + private void CancelOpenStackForm() + { + showOsForm = false; + ResetOsForm(); + } + + private async Task SaveOpenStackConnection() + { + if (string.IsNullOrWhiteSpace(osFormName) || string.IsNullOrWhiteSpace(osFormAuthUrl)) + { + return; + } + + savingOs = true; + StateHasChanged(); + + try + { + await StorageService.CreateOpenStackConnectionAsync( + TenantId, + osFormName.Trim(), + osFormAuthUrl.Trim(), + string.IsNullOrWhiteSpace(osFormRegion) ? null : osFormRegion.Trim(), + string.IsNullOrWhiteSpace(osFormProjectName) ? null : osFormProjectName.Trim(), + string.IsNullOrWhiteSpace(osFormProjectId) ? null : osFormProjectId.Trim(), + string.IsNullOrWhiteSpace(osFormUserDomain) ? null : osFormUserDomain.Trim(), + string.IsNullOrWhiteSpace(osFormProjectDomain) ? null : osFormProjectDomain.Trim(), + string.IsNullOrWhiteSpace(osFormUsername) ? null : osFormUsername.Trim(), + string.IsNullOrWhiteSpace(osFormPassword) ? null : osFormPassword.Trim()); + + showOsForm = false; + ResetOsForm(); + openStackConnections = await StorageService.GetOpenStackConnectionsAsync(TenantId); + } + finally + { + savingOs = false; + } + } + + private async Task DeleteOpenStackConnection(Guid connectionId) + { + bool deleted = await StorageService.DeleteOpenStackConnectionAsync(TenantId, connectionId); + + if (deleted) + { + openStackConnections = await StorageService.GetOpenStackConnectionsAsync(TenantId); + } + } + + // ── Refresh + Helpers ── + + private async Task RefreshAll() + { + refreshing = true; + StateHasChanged(); + + try + { + await LoadData(); + } + finally + { + refreshing = false; + } + } + + private void ResetForm() + { + formName = ""; + formBucket = ""; + formEndpoint = ""; + formRegion = ""; + formAccessKey = ""; + formSecretKey = ""; + formNotes = ""; + formOpenStackConnectionId = Guid.Empty; + formError = null; + } + + private void ResetOsForm() + { + osFormName = ""; + osFormAuthUrl = ""; + osFormRegion = ""; + osFormProjectName = ""; + osFormProjectId = ""; + osFormUserDomain = ""; + osFormProjectDomain = ""; + osFormUsername = ""; + osFormPassword = ""; + } + + private string GetEndpointPlaceholder() => formProvider switch + { + StorageProvider.AwsS3 => "https://s3.eu-west-1.amazonaws.com", + StorageProvider.AzureStorage => "https://.blob.core.windows.net", + StorageProvider.CleuraS3 => "https://s3-.cloudferro.com", + _ => "https://..." + }; + + private static string GetProviderIcon(StorageProvider provider) => provider switch + { + StorageProvider.AwsS3 => "bi bi-cloud", + StorageProvider.AzureStorage => "bi bi-microsoft", + StorageProvider.CleuraS3 => "bi bi-cloud-haze2", + StorageProvider.MinIO => "bi bi-bucket", + _ => "bi bi-cloud" + }; + + private static string GetProviderColor(StorageProvider provider) => provider switch + { + StorageProvider.AwsS3 => "text-warning", + StorageProvider.AzureStorage => "text-primary", + StorageProvider.CleuraS3 => "text-info", + StorageProvider.MinIO => "text-warning", + _ => "" + }; + + private static string GetProviderLabel(StorageProvider provider) => provider switch + { + StorageProvider.AwsS3 => "AWS S3", + StorageProvider.AzureStorage => "Azure Storage", + StorageProvider.CleuraS3 => "Cleura S3", + StorageProvider.MinIO => "MinIO", + _ => provider.ToString() + }; + + private static RenderFragment GetMinioStatusBadge(string? status) => (status ?? "").ToLowerInvariant() switch + { + "initialized" or "running" or "ready" => __builder => + { + Ready + }, + "provisioning" or "waiting" => __builder => + { + Provisioning + }, + "error" or "failed" => __builder => + { + Failed + }, + _ => __builder => + { + @status + } + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor new file mode 100644 index 0000000..7d7222c --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor @@ -0,0 +1,108 @@ +@page "/tenants/{Slug}" +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@rendermode InteractiveServer +@inject TenantService TenantService +@inject NavigationManager Navigation + +@(tenant?.Name ?? "Tenant") + +@if (tenant is null) +{ +
+
+

Loading tenant...

+
+} +else +{ + + +
+ +
+

@tenant.Name

+ @tenant.Slug +
+
+ + + +
+ @switch (activeTab) + { + case "environments": + + break; + case "customers": + + break; + case "vault": + + break; + case "groups": + + break; + case "databases": + + break; + case "storage": + + break; + } +
+} + +@code { + [Parameter] public string Slug { get; set; } = ""; + + private Tenant? tenant; + private string activeTab = "environments"; + + protected override async Task OnInitializedAsync() + { + tenant = await TenantService.GetTenantBySlugAsync(Slug); + + if (tenant is null) + { + Navigation.NavigateTo("/tenants"); + } + } +} \ No newline at end of file diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor new file mode 100644 index 0000000..44ce6cc --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor @@ -0,0 +1,137 @@ +@page "/tenants" +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@rendermode InteractiveServer +@inject TenantService TenantService +@inject NavigationManager Navigation + +Tenants + +
+
+

Tenants

+

Organizations and workspaces in your platform.

+
+
+ +
+
+
+ + + +
+ @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } +
+
+ +@if (tenants is null) +{ +
+
+

Loading tenants...

+
+} +else if (tenants.Count == 0) +{ +
+ +
No tenants yet
+

Create your first tenant above to get started.

+
+} +else +{ +
+ @foreach (Tenant tenant in tenants) + { +
+
+
+
+
+
+ @tenant.Name +
+

+ @tenant.Slug +

+
+ +
+
+ + @if (confirmDeleteId == tenant.Id) + { +
+
+ Delete this tenant? +
+ + +
+
+
+ } + + +
+
+ } +
+} + +@code { + private List? tenants; + private string newTenantName = ""; + private string? errorMessage; + private Guid? confirmDeleteId; + + protected override async Task OnInitializedAsync() + { + await LoadTenants(); + } + + private async Task LoadTenants() + { + tenants = await TenantService.GetAllTenantsAsync(); + } + + private async Task CreateTenant() + { + errorMessage = null; + + try + { + await TenantService.CreateTenantAsync(newTenantName.Trim()); + newTenantName = ""; + await LoadTenants(); + } + catch (DbUpdateException) + { + errorMessage = "A tenant with that name already exists."; + } + } + + private async Task DeleteTenant(Guid id) + { + confirmDeleteId = null; + await TenantService.DeleteTenantAsync(id); + await LoadTenants(); + } +} \ No newline at end of file diff --git a/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor b/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor new file mode 100644 index 0000000..171319f --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor @@ -0,0 +1,583 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject VaultService VaultService +@inject TenantService TenantService +@inject StorageService StorageService + +
+ +

Secrets Vault

+
+

Encrypted secrets for apps and cluster components. Per-tenant AES-256-GCM envelope encryption with auto-unseal.

+ +@if (!vaultInitialized) +{ +
+
+ +
Vault Not Initialized
+

A per-tenant encryption key will be generated and sealed with the platform root key.

+ +
+
+} +else +{ + @* --- Scope Toggle --- *@ +
+ Scope: +
+ + + +
+
+ + @* --- Selector Dropdown --- *@ + @if (scope == "app") + { + @if (appOptions is not null && appOptions.Count > 0) + { +
+
+
+ + +
+
+
+ } + else + { +
+ +

Create a customer and app first to store app secrets.

+
+ } + } + else if (scope == "component") + { + @if (componentOptions is not null && componentOptions.Count > 0) + { +
+
+
+ + +
+
+
+ } + else + { +
+ +

Create an environment, cluster, and component first to store component secrets.

+
+ } + } + else if (scope == "storage") + { + @if (storageLinkOptions is not null && storageLinkOptions.Count > 0) + { +
+
+
+ + +
+
+
+ } + else + { +
+ +

Create a storage link first to view its secrets.

+
+ } + } + + @* --- Secrets Panel --- *@ + @if (HasSelection()) + { +
+
+ + Secrets +
+
+ @* Add secret form *@ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ @errorMessage +
+ } + + @if (!string.IsNullOrEmpty(successMessage)) + { +
+ @successMessage +
+ } + + @* Secrets table *@ + @if (secrets is null) + { +
+
+
+ } + else if (secrets.Count == 0) + { +
+ +

No secrets stored yet. Add one above.

+
+ } + else + { +
+ + + + + + + + + + + + + @foreach (VaultSecret secret in secrets) + { + + + + + + + + + + @* Reveal value row *@ + @if (revealedSecretId == secret.Id) + { + + + + } + + @* Edit value row *@ + @if (editSecretId == secret.Id) + { + + + + } + + @if (syncConfigSecretId == secret.Id) + { + + + + } + } + +
NameK8s SyncK8s SecretNamespaceUpdatedActions
@secret.Name + @if (secret.SyncToKubernetes) + { + Synced + } + else + { + Off + } + @(secret.KubernetesSecretName ?? "—")@(secret.KubernetesNamespace ?? "—")@secret.UpdatedAt.ToString("MMM d, HH:mm") + + + + +
+
+ Value: + @revealedValue +
+
+
+
+ +
+
+ + +
+
+
+
+
+ +
+
+ +
+
+ + +
+
+
+
+ } +
+
+ } +} + +@code { + [Parameter] public Guid TenantId { get; set; } + + private bool vaultInitialized; + private string scope = "app"; + + // App scope + private List<(string Label, Guid Id)>? appOptions; + private Guid selectedAppId; + + // Component scope + private List<(string Label, Guid Id)>? componentOptions; + private Guid selectedComponentId; + + // Storage scope + private List<(string Label, Guid Id)>? storageLinkOptions; + private Guid selectedStorageLinkId; + + // Secrets + private List? secrets; + private string newSecretName = ""; + private string newSecretValue = ""; + private string? errorMessage; + private string? successMessage; + private Guid? syncConfigSecretId; + private string syncSecretName = ""; + private string syncNamespace = ""; + + // Reveal/Edit state + private Guid? revealedSecretId; + private string? revealedValue; + private Guid? editSecretId; + private string editSecretValue = ""; + + protected override async Task OnInitializedAsync() + { + SecretVault? vault = await VaultService.GetVaultAsync(TenantId); + vaultInitialized = vault is not null; + + if (vaultInitialized) + { + await LoadOptions(); + } + } + + private async Task InitializeVault() + { + await VaultService.InitializeVaultAsync(TenantId); + vaultInitialized = true; + await LoadOptions(); + } + + private async Task LoadOptions() + { + List customers = await TenantService.GetCustomersAsync(TenantId); + appOptions = []; + + foreach (Customer customer in customers) + { + List apps = await TenantService.GetAppsAsync(customer.Id); + + foreach (Data.App app in apps) + { + appOptions.Add(($"{customer.Name} / {app.Name}", app.Id)); + } + } + + List clusters = await TenantService.GetClustersAsync(TenantId); + componentOptions = []; + + foreach (KubernetesCluster cluster in clusters) + { + List components = await VaultService.GetComponentsAsync(cluster.Id); + + foreach (ClusterComponent comp in components) + { + componentOptions.Add(($"{cluster.Name} / {comp.Name}", comp.Id)); + } + } + + // Load storage links for the storage secrets scope. + + List links = await StorageService.GetStorageLinksAsync(TenantId); + storageLinkOptions = links + .Select(l => ($"{l.Environment.Name} / {l.Name} ({l.Provider})", l.Id)) + .ToList(); + } + + private void SwitchScope(string newScope) + { + scope = newScope; + secrets = null; + selectedAppId = Guid.Empty; + selectedComponentId = Guid.Empty; + selectedStorageLinkId = Guid.Empty; + errorMessage = null; + successMessage = null; + } + + private bool HasSelection() + { + return (scope == "app" && selectedAppId != Guid.Empty) + || (scope == "component" && selectedComponentId != Guid.Empty) + || (scope == "storage" && selectedStorageLinkId != Guid.Empty); + } + + private async Task LoadSecrets() + { + errorMessage = null; + successMessage = null; + + if (scope == "app" && selectedAppId != Guid.Empty) + { + secrets = await VaultService.GetAppSecretsAsync(TenantId, selectedAppId); + } + else if (scope == "component" && selectedComponentId != Guid.Empty) + { + secrets = await VaultService.GetComponentSecretsAsync(TenantId, selectedComponentId); + } + else if (scope == "storage" && selectedStorageLinkId != Guid.Empty) + { + secrets = await VaultService.GetStorageLinkSecretsAsync(TenantId, selectedStorageLinkId); + } + else + { + secrets = null; + } + } + + private async Task AddSecret() + { + errorMessage = null; + successMessage = null; + + try + { + if (scope == "app") + { + await VaultService.SetAppSecretAsync(TenantId, selectedAppId, newSecretName.Trim(), newSecretValue.Trim()); + } + else if (scope == "component") + { + await VaultService.SetComponentSecretAsync(TenantId, selectedComponentId, newSecretName.Trim(), newSecretValue.Trim()); + } + else if (scope == "storage") + { + await VaultService.SetStorageLinkSecretAsync(TenantId, selectedStorageLinkId, newSecretName.Trim(), newSecretValue.Trim()); + } + + successMessage = $"Secret '{newSecretName.Trim()}' saved."; + newSecretName = ""; + newSecretValue = ""; + await LoadSecrets(); + } + catch (Exception ex) + { + errorMessage = ex.Message; + } + } + + private async Task DeleteSecret(Guid secretId) + { + errorMessage = null; + + // Check if deletion is blocked by active storage bindings. + + (bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId); + + if (!canDelete) + { + errorMessage = reason; + return; + } + + await VaultService.DeleteSecretAsync(secretId); + revealedSecretId = null; + editSecretId = null; + await LoadSecrets(); + } + + private async Task RevealSecret(VaultSecret secret) + { + // Toggle off if already revealed. + + if (revealedSecretId == secret.Id) + { + revealedSecretId = null; + revealedValue = null; + return; + } + + // Decrypt and reveal the value. + + revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id); + revealedSecretId = secret.Id; + } + + private void StartEdit(VaultSecret secret) + { + editSecretId = secret.Id; + editSecretValue = ""; + revealedSecretId = null; + revealedValue = null; + } + + private void CancelEdit() + { + editSecretId = null; + editSecretValue = ""; + } + + private async Task SaveEdit() + { + if (editSecretId is null || string.IsNullOrWhiteSpace(editSecretValue)) + { + return; + } + + errorMessage = null; + + bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim()); + + if (updated) + { + successMessage = "Secret value updated."; + } + else + { + errorMessage = "Failed to update secret."; + } + + editSecretId = null; + editSecretValue = ""; + await LoadSecrets(); + } + + private void ToggleSync(VaultSecret secret) + { + if (secret.SyncToKubernetes) + { + _ = DisableSync(secret.Id); + } + else + { + syncConfigSecretId = secret.Id; + syncSecretName = secret.KubernetesSecretName ?? ""; + syncNamespace = secret.KubernetesNamespace ?? ""; + } + } + + private async Task DisableSync(Guid secretId) + { + await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null); + await LoadSecrets(); + } + + private async Task SaveSync() + { + if (syncConfigSecretId is null) + { + return; + } + + await VaultService.ConfigureKubernetesSyncAsync( + syncConfigSecretId.Value, + syncEnabled: true, + secretName: syncSecretName.Trim(), + ns: syncNamespace.Trim()); + + syncConfigSecretId = null; + await LoadSecrets(); + } +} \ No newline at end of file diff --git a/src/EntKube.Web/Components/Provisioning/AppsProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/AppsProxyEndpoints.cs deleted file mode 100644 index b0d86fc..0000000 --- a/src/EntKube.Web/Components/Provisioning/AppsProxyEndpoints.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for app management. The Blazor frontend calls these -/// endpoints, and the BFF forwards requests to the Provisioning microservice. -/// Apps represent customer workloads (Deployments or Helm charts) that can -/// be deployed across multiple environments. -/// -public static class AppsProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/apps") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/apps/by-customer/{customerId} — list all apps for a customer. - - group.MapGet("by-customer/{customerId:guid}", async ( - Guid customerId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/customers/{customerId}/apps", ct); - }); - - // GET /api/apps/{appId} — get full app detail with environments. - - group.MapGet("{appId:guid}", async ( - Guid appId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/apps/{appId}", ct); - }); - - // POST /api/apps/by-customer/{customerId} — create a new app. - - group.MapPost("by-customer/{customerId:guid}", async ( - Guid customerId, - CreateAppProxyRequest request, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/customers/{customerId}/apps", request, ct); - }); - - // POST /api/apps/{appId}/environments — add app to an environment. - - group.MapPost("{appId:guid}/environments", async ( - Guid appId, - AddEnvironmentProxyRequest request, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/apps/{appId}/environments", request, ct); - }); - - // PUT /api/apps/{appId}/environments/{environmentId}/deployment — configure deployment spec. - - group.MapPut("{appId:guid}/environments/{environmentId:guid}/deployment", async ( - Guid appId, - Guid environmentId, - DeploymentSpecProxy spec, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPutAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/deployment", spec, ct); - }); - - // PUT /api/apps/{appId}/environments/{environmentId}/helm — configure helm release spec. - - group.MapPut("{appId:guid}/environments/{environmentId:guid}/helm", async ( - Guid appId, - Guid environmentId, - HelmReleaseSpecProxy spec, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPutAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/helm", spec, ct); - }); - - // POST /api/apps/{appId}/environments/{environmentId}/secrets — add a secret reference. - - group.MapPost("{appId:guid}/environments/{environmentId:guid}/secrets", async ( - Guid appId, - Guid environmentId, - AddSecretProxyRequest request, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/secrets", request, ct); - }); - - // DELETE /api/apps/{appId} — delete an app and all its environments. - - group.MapDelete("{appId:guid}", async ( - Guid appId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}", ct); - }); - - // DELETE /api/apps/{appId}/environments/{environmentId} — remove an environment from an app. - - group.MapDelete("{appId:guid}/environments/{environmentId:guid}", async ( - Guid appId, - Guid environmentId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}", ct); - }); - - // DELETE /api/apps/{appId}/environments/{environmentId}/secrets/{secretName} — remove a secret. - - group.MapDelete("{appId:guid}/environments/{environmentId:guid}/secrets/{secretName}", async ( - Guid appId, - Guid environmentId, - string secretName, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/secrets/{secretName}", ct); - }); - - // POST /api/apps/{appId}/suspend — suspend an app. - - group.MapPost("{appId:guid}/suspend", async ( - Guid appId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/suspend", ct); - }); - - // POST /api/apps/{appId}/activate — re-activate a suspended app. - - group.MapPost("{appId:guid}/activate", async ( - Guid appId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/activate", ct); - }); - - // POST /api/apps/{appId}/environments/{environmentId}/sync — trigger immediate sync. - - group.MapPost("{appId:guid}/environments/{environmentId:guid}/sync", async ( - Guid appId, - Guid environmentId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/sync", ct); - }); - - // ─── Cluster Resource Lookups ──────────────────────────────────── - // These proxy to the Provisioning service which talks to the cluster. - - // GET /api/apps/clusters/{clusterId}/namespaces/{ns}/service-accounts — list service accounts. - - group.MapGet("clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async ( - Guid clusterId, - string ns, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/service-accounts", ct); - }); - - // GET /api/apps/clusters/{clusterId}/namespaces/{ns}/pvcs — list PVCs. - - group.MapGet("clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async ( - Guid clusterId, - string ns, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyGetAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/pvcs", ct); - }); - - // POST /api/apps/clusters/{clusterId}/namespaces/{ns}/service-accounts — create a service account. - - group.MapPost("clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async ( - Guid clusterId, - string ns, - CreateResourceRequest request, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/service-accounts", request, ct); - }); - - // POST /api/apps/clusters/{clusterId}/namespaces/{ns}/pvcs — create a PVC. - - group.MapPost("clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async ( - Guid clusterId, - string ns, - CreatePvcRequest request, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - return await ProxyPostAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/pvcs", request, ct); - }); - } - - // ─── Shared proxy helpers ───────────────────────────────────────────── - - private static async Task ProxyGetAsync( - IHttpClientFactory httpClientFactory, string path, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync(path, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private static async Task ProxyPostAsync( - IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsJsonAsync(path, payload, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private static async Task ProxyPostEmptyAsync( - IHttpClientFactory httpClientFactory, string path, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsync(path, null, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private static async Task ProxyPutAsync( - IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync(path, payload, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private static async Task ProxyDeleteAsync( - IHttpClientFactory httpClientFactory, string path, CancellationToken ct) - { - try - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync(path, ct); - - if (!response.IsSuccessStatusCode) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Json( - new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"), - statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - catch (HttpRequestException) - { - return Results.Json( - new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."), - statusCode: 502); - } - } - - private record ApiError(bool Success, string Error); -} - -// ─── Proxy DTOs ────────────────────────────────────────────────────────── - -public record CreateAppProxyRequest(Guid TenantId, string Name, string Slug, string Type); -public record AddEnvironmentProxyRequest(Guid EnvironmentId, Guid ClusterId, string Namespace); -public record AddSecretProxyRequest(string Name, string VaultKey); - -public record DeploymentSpecProxy( - string Image, - string Tag, - int Replicas, - int ContainerPort, - int ServicePort, - string? HostName, - string? PathPrefix, - Dictionary? EnvironmentVariables, - ResourceSpecProxy? Resources); - -public record ResourceSpecProxy( - string? CpuRequest, - string? CpuLimit, - string? MemoryRequest, - string? MemoryLimit); - -public record HelmReleaseSpecProxy( - string RepoUrl, - string ChartName, - string ChartVersion, - string? ValuesYaml); - -public record CreateResourceRequest(string Name); - -public record CreatePvcRequest( - string Name, - string StorageSize, - string AccessMode = "ReadWriteOnce", - string? StorageClass = null); diff --git a/src/EntKube.Web/Components/Provisioning/GiteaProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/GiteaProxyEndpoints.cs deleted file mode 100644 index 694c823..0000000 --- a/src/EntKube.Web/Components/Provisioning/GiteaProxyEndpoints.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for Gitea operations. The Blazor frontend calls these -/// to fetch Gitea instance info and runner statuses. Requests are forwarded -/// to the Clusters microservice which has direct K8s API access. -/// -public static class GiteaProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/gitea") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/gitea/{clusterId}/info — Gitea instance info + runners. - - group.MapGet("{clusterId:guid}/info", async ( - Guid clusterId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync( - $"/api/clusters/{clusterId}/gitea/info", ct); - - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Content(body, "application/json", statusCode: (int)response.StatusCode); - }); - } -} diff --git a/src/EntKube.Web/Components/Provisioning/GrafanaProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/GrafanaProxyEndpoints.cs deleted file mode 100644 index a8571f1..0000000 --- a/src/EntKube.Web/Components/Provisioning/GrafanaProxyEndpoints.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for Grafana instance management. Forwards requests from -/// the Blazor frontend to the Provisioning microservice's Grafana endpoints. -/// -public static class GrafanaProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/grafana") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/grafana — list all Grafana instances - group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync("/api/grafana", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/grafana/environment/{environmentId} - group.MapGet("environment/{environmentId:guid}", async (Guid environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/grafana/environment/{environmentId}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/grafana/{id} - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/grafana/{id}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/grafana — create a new Grafana instance - group.MapPost("", async (CreateGrafanaInstanceProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsJsonAsync("/api/grafana", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/grafana/{id} - group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/grafana/{id}/persistence - group.MapPut("{id:guid}/persistence", async (Guid id, ConfigurePersistenceProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/persistence", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/grafana/{id}/oidc - group.MapPut("{id:guid}/oidc", async (Guid id, ConfigureOidcProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/oidc", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/grafana/{id}/oidc - group.MapDelete("{id:guid}/oidc", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/oidc", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/grafana/{id}/ingress - group.MapPut("{id:guid}/ingress", async (Guid id, ConfigureGrafanaIngressProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/ingress", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/grafana/{id}/ingress - group.MapDelete("{id:guid}/ingress", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/ingress", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/grafana/{id}/dashboards - group.MapGet("{id:guid}/dashboards", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/grafana/{id}/dashboards", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/grafana/{id}/dashboards - group.MapPost("{id:guid}/dashboards", async (Guid id, AddDashboardProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsJsonAsync($"/api/grafana/{id}/dashboards", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/grafana/{id}/dashboards/{name} - group.MapDelete("{id:guid}/dashboards/{name}", async (Guid id, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/dashboards/{name}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - } -} - -// ─── Proxy Request DTOs ───────────────────────────────────────────── - -public record CreateGrafanaInstanceProxyRequest(Guid EnvironmentId, Guid ClusterId, string Name, string Namespace); - -public record ConfigurePersistenceProxyRequest(bool Enabled, string? Size); - -public record ConfigureOidcProxyRequest( - string Provider, string ClientId, string ClientSecretRef, - string AuthUrl, string TokenUrl, string ApiUrl, - string Scopes, string? RoleAttributePath, bool AutoLogin); - -public record ConfigureGrafanaIngressProxyRequest( - bool Enabled, string Provider, - string? Hostname, bool TlsEnabled, - string TlsCertificateMode = "Manual", - string? TlsSecretName = null, string? ClusterIssuer = null); - -public record AddDashboardProxyRequest(string Name, string DisplayName, string Category, string JsonContent); diff --git a/src/EntKube.Web/Components/Provisioning/HarborProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/HarborProxyEndpoints.cs deleted file mode 100644 index 0855945..0000000 --- a/src/EntKube.Web/Components/Provisioning/HarborProxyEndpoints.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for Harbor operations. The Blazor frontend calls these -/// endpoints to list Harbor projects and create pull secrets. Requests are -/// forwarded to the Clusters microservice which has direct Harbor API access. -/// -public static class HarborProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/harbor") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/harbor/{clusterId}/projects — list Harbor projects on a cluster. - - group.MapGet("{clusterId:guid}/projects", async ( - Guid clusterId, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.GetAsync( - $"/api/clusters/{clusterId}/harbor/projects", ct); - - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Content(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/harbor/{clusterId}/pull-secret — create a Harbor pull secret. - - group.MapPost("{clusterId:guid}/pull-secret", async ( - Guid clusterId, - CreateHarborPullSecretProxyRequest request, - IHttpClientFactory httpClientFactory, - CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage response = await client.PostAsJsonAsync( - $"/api/clusters/{clusterId}/harbor/pull-secret", request, ct); - - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Content(body, "application/json", statusCode: (int)response.StatusCode); - }); - } -} - -public record CreateHarborPullSecretProxyRequest( - string ProjectName, - string AppSlug, - string TargetNamespace); diff --git a/src/EntKube.Web/Components/Provisioning/KeycloakRealmProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/KeycloakRealmProxyEndpoints.cs deleted file mode 100644 index 54eaa3a..0000000 --- a/src/EntKube.Web/Components/Provisioning/KeycloakRealmProxyEndpoints.cs +++ /dev/null @@ -1,135 +0,0 @@ -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for Keycloak realm management. The Blazor frontend calls these -/// endpoints, and the BFF forwards requests to the Provisioning microservice. -/// -public static class KeycloakRealmProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/keycloak-realms") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/keycloak-realms/cluster/{clusterId} — list realms for a cluster. - - group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/keycloak-realms/cluster/{clusterId}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/keycloak-realms/{id} — get a single realm. - - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/keycloak-realms/{id}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/keycloak-realms — create a new realm. - - group.MapPost("", async (HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - StreamContent content = new(httpContext.Request.Body); - content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( - httpContext.Request.ContentType ?? "application/json"); - - HttpResponseMessage response = await client.PostAsync("/api/keycloak-realms", content, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/keycloak-realms/{id} — delete a realm. - - group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/keycloak-realms/{id}/branding — update realm branding. - - group.MapPut("{id:guid}/branding", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - StreamContent content = new(httpContext.Request.Body); - content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( - httpContext.Request.ContentType ?? "application/json"); - - HttpResponseMessage response = await client.PutAsync($"/api/keycloak-realms/{id}/branding", content, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/keycloak-realms/{id}/pages — update realm pages config. - - group.MapPut("{id:guid}/pages", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - StreamContent content = new(httpContext.Request.Body); - content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( - httpContext.Request.ContentType ?? "application/json"); - - HttpResponseMessage response = await client.PutAsync($"/api/keycloak-realms/{id}/pages", content, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/keycloak-realms/{id}/identity-providers — add identity provider. - - group.MapPost("{id:guid}/identity-providers", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - StreamContent content = new(httpContext.Request.Body); - content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( - httpContext.Request.ContentType ?? "application/json"); - - HttpResponseMessage response = await client.PostAsync($"/api/keycloak-realms/{id}/identity-providers", content, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/keycloak-realms/{id}/identity-providers/{alias} — remove identity provider. - - group.MapDelete("{id:guid}/identity-providers/{alias}", async (Guid id, string alias, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}/identity-providers/{alias}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/keycloak-realms/{id}/organizations — add organization. - - group.MapPost("{id:guid}/organizations", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - StreamContent content = new(httpContext.Request.Body); - content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse( - httpContext.Request.ContentType ?? "application/json"); - - HttpResponseMessage response = await client.PostAsync($"/api/keycloak-realms/{id}/organizations", content, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/keycloak-realms/{id}/organizations/{name} — remove organization. - - group.MapDelete("{id:guid}/organizations/{name}", async (Guid id, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}/organizations/{name}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - } -} diff --git a/src/EntKube.Web/Components/Provisioning/MinioTenantProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/MinioTenantProxyEndpoints.cs deleted file mode 100644 index 35cc6bb..0000000 --- a/src/EntKube.Web/Components/Provisioning/MinioTenantProxyEndpoints.cs +++ /dev/null @@ -1,339 +0,0 @@ -using System.Net.Http.Json; -using System.Text; -using System.Text.Json.Nodes; -using Microsoft.AspNetCore.Mvc; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for MinIO tenant management. The Blazor frontend calls these -/// endpoints, and the BFF forwards requests to the Provisioning microservice. -/// -/// The BFF enriches requests with the cluster's kubeconfig (fetched from the Clusters -/// service) so the frontend never needs to handle sensitive credentials. -/// -public static class MinioTenantProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/minio-tenants") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/minio-tenants — list all MinIO tenants. - - group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync("/api/minio-tenants", ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/minio-tenants/cluster/{clusterId} — list tenants for a specific cluster. - - group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/minio-tenants/cluster/{clusterId}", ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/minio-tenants/{id} — get a specific tenant. - - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/minio-tenants/{id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/minio-tenants — create a new MinIO tenant. - // The BFF fetches the kubeconfig from the Clusters service and injects it. - - group.MapPost("", async (CreateMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - // Fetch cluster credentials from the Clusters service. - - ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct); - - if (creds is null) - { - return Results.BadRequest(new { error = "Could not fetch cluster credentials." }); - } - - // Forward to Provisioning with credentials injected. - - object provisioningRequest = new - { - request.ClusterId, - creds.KubeConfig, - creds.ContextName, - request.Name, - request.Namespace, - request.Pools - }; - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsJsonAsync("/api/minio-tenants", provisioningRequest, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // PUT /api/minio-tenants/{id}/configure — update tenant pool configuration. - - group.MapPut("{id:guid}/configure", async (Guid id, ConfigureMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - // Fetch cluster credentials. - - ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct); - - if (creds is null) - { - return Results.BadRequest(new { error = "Could not fetch cluster credentials." }); - } - - object provisioningRequest = new - { - creds.KubeConfig, - creds.ContextName, - request.Pools - }; - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/minio-tenants/{id}/configure", provisioningRequest, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // DELETE /api/minio-tenants/{id} — delete a tenant from the cluster. - - group.MapDelete("{id:guid}", async (Guid id, [FromBody] DeleteMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - // Fetch cluster credentials. - - ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct); - - if (creds is null) - { - return Results.BadRequest(new { error = "Could not fetch cluster credentials." }); - } - - object provisioningRequest = new - { - creds.KubeConfig, - creds.ContextName - }; - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpRequestMessage deleteRequest = new(HttpMethod.Delete, $"/api/minio-tenants/{id}") - { - Content = JsonContent.Create(provisioningRequest) - }; - HttpResponseMessage response = await client.SendAsync(deleteRequest, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/minio-tenants/adopt — discover and adopt existing MinIO tenants. - // The frontend sends clusterId, the BFF injects kubeConfig/contextName. - - group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/minio-tenants/adopt", "POST", ct); - }); - - // POST /api/minio-tenants/{id}/health — live health from K8s. - // BFF resolves credentials and forwards via POST (kubeConfig is too large for query strings). - - group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/minio-tenants/{id}/health", ct); - }); - - // GET /api/minio-tenants/{id}/buckets — list buckets (forwarded as POST). - - group.MapGet("{id:guid}/buckets", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/minio-tenants/{id}/buckets", ct); - }); - - // POST /api/minio-tenants/{id}/buckets/create — create a bucket. - - group.MapPost("{id:guid}/buckets/create", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/minio-tenants/{id}/buckets/create", "POST", ct); - }); - - // POST /api/minio-tenants/{id}/buckets/delete — delete a bucket. - - group.MapPost("{id:guid}/buckets/delete", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/minio-tenants/{id}/buckets/delete", "POST", ct); - }); - } - - /// - /// Fetches the kubeconfig and context name for a cluster from the Clusters service. - /// This is an internal BFF-to-service call — credentials never reach the frontend. - /// - private static async Task GetClusterCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct) - { - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct); - - if (!credsResponse.IsSuccessStatusCode) - { - return null; - } - - ApiResponseWrapper? result = - await credsResponse.Content.ReadFromJsonAsync>(ct); - - return result?.Data; - } - - /// - /// Reads the request body, resolves kubeConfig from the Clusters service using - /// the clusterId in the body, injects the credentials, and forwards to Provisioning. - /// - private static async Task ForwardWithCredentials( - HttpRequest request, IHttpClientFactory httpClientFactory, - string provisioningPath, string httpMethod, CancellationToken ct) - { - JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct); - - if (body is null) - { - return Results.BadRequest("Request body is required."); - } - - Guid? clusterId = body["clusterId"]?.GetValue(); - - if (clusterId is null) - { - return Results.BadRequest("clusterId is required."); - } - - ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - body["kubeConfig"] = creds.KubeConfig; - body["contextName"] = creds.ContextName; - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "PUT" - ? await client.PutAsync(provisioningPath, content, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(errorBody, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - /// - /// Resolves kubeConfig for a cluster and forwards a GET-style request as a POST - /// with credentials in the body. Used for read operations where the kubeConfig - /// payload is too large for query strings. - /// - private static async Task ForwardGetWithCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, - string provisioningPath, CancellationToken ct) - { - ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - JsonNode body = new JsonObject - { - ["kubeConfig"] = creds.KubeConfig, - ["contextName"] = creds.ContextName - }; - - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string error = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(error, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } -} - -// ─── Frontend DTOs (no kubeconfig — BFF injects it) ───────────────────────── - -public record CreateMinioTenantFrontendRequest( - Guid ClusterId, - string Name, - string Namespace, - List Pools); - -public record ConfigureMinioTenantFrontendRequest( - Guid ClusterId, - List Pools); - -public record DeleteMinioTenantFrontendRequest( - Guid ClusterId); - -public record PoolSpecProxy( - int Servers, - int VolumesPerServer, - string StorageSize, - string StorageClass, - string? CpuRequest = null, - string? CpuLimit = null, - string? MemoryRequest = null, - string? MemoryLimit = null); - -// ─── Internal response types ───────────────────────────────────────────────── - -internal record ClusterCredentialsResponse(string KubeConfig, string ContextName); - -internal record ApiResponseWrapper -{ - public bool Success { get; init; } - public T? Data { get; init; } - public string? Error { get; init; } -} diff --git a/src/EntKube.Web/Components/Provisioning/MongoClusterProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/MongoClusterProxyEndpoints.cs deleted file mode 100644 index faadf9c..0000000 --- a/src/EntKube.Web/Components/Provisioning/MongoClusterProxyEndpoints.cs +++ /dev/null @@ -1,329 +0,0 @@ -using System.Text; -using System.Text.Json.Nodes; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for MongoDB cluster management. The Blazor frontend calls -/// these endpoints, and the BFF forwards requests to the Provisioning -/// microservice's /api/mongo-clusters routes. -/// -/// The frontend never sends kubeConfig — the BFF resolves it server-side by -/// calling the Clusters service's /api/clusters/{id}/credentials endpoint, -/// then injects it into the request before forwarding to Provisioning. -/// -public static class MongoClusterProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/mongo-clusters") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/mongo-clusters — list clusters, optionally filtered by clusterId. - - group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - string url = clusterId.HasValue - ? $"/api/mongo-clusters?clusterId={clusterId.Value}" - : "/api/mongo-clusters"; - - HttpResponseMessage response = await client.GetAsync(url, ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/mongo-clusters/{id} — get a single cluster. - - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/mongo-clusters/{id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/mongo-clusters — create a new MongoDB cluster. - // BFF enriches with bucket credentials so Provisioning can create the K8s - // secret directly without depending on a pre-existing copy in cnpg-system. - - group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/mongo-clusters", "POST", ct, - enrichBody: async (body, factory, cancellation) => - { - await EnrichWithBucketCredentials(body, factory, cancellation); - }); - }); - - // POST /api/mongo-clusters/adopt — discover and adopt existing clusters. - - group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/mongo-clusters/adopt", "POST", ct); - }); - - // POST /api/mongo-clusters/{id}/upgrade — initiate version upgrade. - - group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/upgrade", "POST", ct); - }); - - // PUT /api/mongo-clusters/{id}/configure — reconfigure settings. - - group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/configure", "PUT", ct); - }); - - // GET /api/mongo-clusters/{id}/health — live health from K8s. - - group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/mongo-clusters/{id}/health", ct); - }); - - // POST /api/mongo-clusters/{id}/restart — rolling restart. - - group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/restart", "POST", ct); - }); - - // POST /api/mongo-clusters/{id}/stepdown — primary stepdown. - - group.MapPost("{id:guid}/stepdown", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/stepdown", "POST", ct); - }); - - // DELETE /api/mongo-clusters/{id} — delete a MongoDB cluster. - - group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/mongo-clusters/{id}", ct, "DELETE"); - }); - - // DELETE /api/mongo-clusters/{id}/databases/{dbName} — drop a database. - - group.MapDelete("{id:guid}/databases/{dbName}", async (Guid id, string dbName, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/mongo-clusters/{id}/databases/{dbName}", ct, "DELETE"); - }); - - // GET /api/mongo-clusters/available-versions — list supported MongoDB versions. - - group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, "/api/mongo-clusters/available-versions", ct); - }); - } - - /// - /// Forwards a request to Provisioning by injecting kubeConfig+contextName into - /// the body as a POST. This avoids putting large kubeConfig data into query - /// strings, which can exceed URL length limits. - /// - private static async Task ForwardGetWithCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, - string provisioningPath, CancellationToken ct, string httpMethod = "POST") - { - ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - JsonNode body = new JsonObject - { - ["kubeConfig"] = creds.KubeConfig, - ["contextName"] = creds.ContextName - }; - - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "DELETE" - ? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string error = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(error, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - /// - /// Reads the request body, resolves kubeConfig from the Clusters service using - /// the clusterId in the body, injects the credentials, and forwards to Provisioning. - /// An optional enrichBody callback can modify the body before forwarding. - /// - private static async Task ForwardWithCredentials( - HttpRequest request, IHttpClientFactory httpClientFactory, - string provisioningPath, string httpMethod, CancellationToken ct, - Func? enrichBody = null) - { - JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct); - - if (body is null) - { - return Results.BadRequest("Request body is required."); - } - - Guid? clusterId = body["clusterId"]?.GetValue(); - - if (clusterId is null) - { - return Results.BadRequest("clusterId is required."); - } - - ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - body["kubeConfig"] = creds.KubeConfig; - body["contextName"] = creds.ContextName; - - if (enrichBody is not null) - { - await enrichBody(body, httpClientFactory, ct); - } - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "PUT" - ? await client.PutAsync(provisioningPath, content, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(errorBody, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - private static async Task GetClusterCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct) - { - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct); - - if (!credsResponse.IsSuccessStatusCode) - { - return null; - } - - JsonNode? credsBody = await JsonNode.ParseAsync( - await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue(); - string? contextName = credsBody?["data"]?["contextName"]?.GetValue(); - - if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName)) - { - return null; - } - - return new ClusterCreds(kubeConfig, contextName); - } - - private record ClusterCreds(string KubeConfig, string ContextName); - - /// - /// Fetches bucket credentials from the Clusters service and injects them into the - /// request body so the Provisioning service can create the secret directly. - /// - private static async Task EnrichWithBucketCredentials( - JsonNode body, IHttpClientFactory httpClientFactory, CancellationToken ct) - { - Guid? clusterId = body["clusterId"]?.GetValue(); - string? endpoint = body["minioEndpoint"]?.GetValue(); - - if (clusterId is null || string.IsNullOrWhiteSpace(endpoint)) - { - return; - } - - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage bucketsResponse = await clustersClient.GetAsync( - $"/api/clusters/{clusterId}/storage/buckets", ct); - - if (!bucketsResponse.IsSuccessStatusCode) - { - return; - } - - JsonNode? bucketsBody = await JsonNode.ParseAsync( - await bucketsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - JsonArray? buckets = bucketsBody?["data"]?.AsArray(); - - if (buckets is null) - { - return; - } - - foreach (JsonNode? bucket in buckets) - { - if (bucket is null) - { - continue; - } - - string? bucketEndpoint = bucket["endpoint"]?.GetValue(); - - if (string.Equals(bucketEndpoint, endpoint, StringComparison.OrdinalIgnoreCase)) - { - Guid bucketId = bucket["id"]!.GetValue(); - HttpResponseMessage detailResponse = await clustersClient.GetAsync( - $"/api/clusters/{clusterId}/storage/buckets/{bucketId}", ct); - - if (!detailResponse.IsSuccessStatusCode) - { - return; - } - - JsonNode? detailBody = await JsonNode.ParseAsync( - await detailResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - string? accessKey = detailBody?["data"]?["accessKey"]?.GetValue(); - string? secretKey = detailBody?["data"]?["secretKey"]?.GetValue(); - string? region = detailBody?["data"]?["region"]?.GetValue(); - - if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey)) - { - body["s3AccessKey"] = accessKey; - body["s3SecretKey"] = secretKey; - - if (!string.IsNullOrWhiteSpace(region)) - { - body["s3Region"] = region; - } - } - - return; - } - } - } -} diff --git a/src/EntKube.Web/Components/Provisioning/PostgresClusterProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/PostgresClusterProxyEndpoints.cs deleted file mode 100644 index b29c98e..0000000 --- a/src/EntKube.Web/Components/Provisioning/PostgresClusterProxyEndpoints.cs +++ /dev/null @@ -1,398 +0,0 @@ -using System.Text; -using System.Text.Json.Nodes; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for PostgreSQL cluster management. The Blazor frontend -/// calls these endpoints, and the BFF forwards requests to the Provisioning -/// microservice's /api/postgres-clusters routes. -/// -/// The frontend never sends kubeConfig — the BFF resolves it server-side by -/// calling the Clusters service's /api/clusters/{id}/credentials endpoint, -/// then injects it into the request before forwarding to Provisioning. -/// -public static class PostgresClusterProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/postgres-clusters") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/postgres-clusters — list clusters, optionally filtered by clusterId. - - group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - string url = clusterId.HasValue - ? $"/api/postgres-clusters?clusterId={clusterId.Value}" - : "/api/postgres-clusters"; - - HttpResponseMessage response = await client.GetAsync(url, ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/postgres-clusters/{id} — get a single cluster. - - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/postgres-clusters/{id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/postgres-clusters — create a new PG cluster. - // Frontend sends clusterId + config. BFF injects kubeConfig/contextName - // and bucket credentials so the Provisioning service can create the K8s - // secret directly without depending on a pre-existing copy in cnpg-system. - - group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/postgres-clusters", "POST", ct, - enrichBody: async (body, factory, cancellation) => - { - await EnrichWithBucketCredentials(body, factory, cancellation); - }); - }); - - // POST /api/postgres-clusters/adopt — discover and adopt existing clusters. - - group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/postgres-clusters/adopt", "POST", ct); - }); - - // POST /api/postgres-clusters/{id}/databases — add a database. - - group.MapPost("{id:guid}/databases", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/databases", "POST", ct); - }); - - // POST /api/postgres-clusters/{id}/upgrade — initiate version upgrade. - - group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/upgrade", "POST", ct); - }); - - // PUT /api/postgres-clusters/{id}/configure — reconfigure settings. - - group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/configure", "PUT", ct); - }); - - // GET /api/postgres-clusters/{id}/health — live health from K8s. - // The BFF resolves credentials server-side and forwards to Provisioning - // via POST (kubeConfig is too large for query strings). - - group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/health", ct); - }); - - // GET /api/postgres-clusters/{id}/backups — list backups. - - group.MapGet("{id:guid}/backups", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/backups", ct); - }); - - // GET /api/postgres-clusters/{id}/backups/diagnose — raw backup diagnostics. - - group.MapGet("{id:guid}/backups/diagnose", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/backups/diagnose", ct); - }); - - // POST /api/postgres-clusters/{id}/backup — trigger on-demand backup. - - group.MapPost("{id:guid}/backup", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/backup", "POST", ct); - }); - - // POST /api/postgres-clusters/{id}/restart — rolling restart. - - group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restart", "POST", ct); - }); - - // POST /api/postgres-clusters/{id}/failover — controlled switchover. - - group.MapPost("{id:guid}/failover", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/failover", "POST", ct); - }); - - // DELETE /api/postgres-clusters/{id}/databases/{dbName} — drop a database. - - group.MapDelete("{id:guid}/databases/{dbName}", async (Guid id, string dbName, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/databases/{dbName}", ct, "DELETE"); - }); - - // DELETE /api/postgres-clusters/{id} — delete the entire cluster. - - group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/postgres-clusters/{id}", ct, "DELETE"); - }); - - // POST /api/postgres-clusters/{id}/restore — restore from backup. - - group.MapPost("{id:guid}/restore", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restore", "POST", ct); - }); - - // POST /api/postgres-clusters/{id}/restore/preview — dry-run restore preview. - - group.MapPost("{id:guid}/restore/preview", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restore/preview", "POST", ct); - }); - - // GET /api/postgres-clusters/available-versions — list supported PG versions. - - group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, "/api/postgres-clusters/available-versions", ct); - }); - - // GET /api/postgres-clusters/{id}/databases/live — live database listing. - - group.MapGet("{id:guid}/databases/live", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/databases/live", ct); - }); - } - - /// - /// Forwards a request to Provisioning by injecting kubeConfig+contextName into - /// the body as a POST. This avoids putting large kubeConfig data into query - /// strings, which can exceed URL length limits. - /// - private static async Task ForwardGetWithCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, - string provisioningPath, CancellationToken ct, string httpMethod = "POST") - { - ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - JsonNode body = new JsonObject - { - ["kubeConfig"] = creds.KubeConfig, - ["contextName"] = creds.ContextName - }; - - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "DELETE" - ? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string error = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(error, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - /// - /// Reads the request body, resolves kubeConfig from the Clusters service using - /// the clusterId in the body, injects the credentials, and forwards to Provisioning. - /// An optional enrichBody callback can modify the body before forwarding. - /// - private static async Task ForwardWithCredentials( - HttpRequest request, IHttpClientFactory httpClientFactory, - string provisioningPath, string httpMethod, CancellationToken ct, - Func? enrichBody = null) - { - JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct); - - if (body is null) - { - return Results.BadRequest("Request body is required."); - } - - // Resolve kubeConfig from the Clusters service. - - Guid? clusterId = body["clusterId"]?.GetValue(); - - if (clusterId is null) - { - return Results.BadRequest("clusterId is required."); - } - - ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - body["kubeConfig"] = creds.KubeConfig; - body["contextName"] = creds.ContextName; - - // Allow callers to enrich the body with additional data before forwarding. - - if (enrichBody is not null) - { - await enrichBody(body, httpClientFactory, ct); - } - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "PUT" - ? await client.PutAsync(provisioningPath, content, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(errorBody, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - /// - /// Fetches kubeConfig and contextName from the Clusters service for a given - /// Kubernetes cluster ID. This is a server-to-server call — the frontend - /// never sees the kubeConfig. - /// - private static async Task GetClusterCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct) - { - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct); - - if (!credsResponse.IsSuccessStatusCode) - { - return null; - } - - JsonNode? credsBody = await JsonNode.ParseAsync( - await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue(); - string? contextName = credsBody?["data"]?["contextName"]?.GetValue(); - - if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName)) - { - return null; - } - - return new ClusterCreds(kubeConfig, contextName); - } - - private record ClusterCreds(string KubeConfig, string ContextName); - - /// - /// When the frontend selected a storage bucket for backups, fetch the bucket's - /// access key and secret key from the Clusters service and inject them into the - /// request body. This allows the Provisioning service to create the K8s secret - /// directly in the target namespace without depending on a copy in cnpg-system. - /// - private static async Task EnrichWithBucketCredentials( - JsonNode body, IHttpClientFactory httpClientFactory, CancellationToken ct) - { - // The frontend sends minioCredentialsSecret = "cnpg-backup-creds" and - // clusterId. We need to find which bucket was selected to get its credentials. - // The frontend doesn't send bucketId to the provisioning path, but we can - // look up the bucket by matching the endpoint that was sent. - - Guid? clusterId = body["clusterId"]?.GetValue(); - string? endpoint = body["minioEndpoint"]?.GetValue(); - - if (clusterId is null || string.IsNullOrWhiteSpace(endpoint)) - { - return; - } - - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage bucketsResponse = await clustersClient.GetAsync( - $"/api/clusters/{clusterId}/storage/buckets", ct); - - if (!bucketsResponse.IsSuccessStatusCode) - { - return; - } - - JsonNode? bucketsBody = await JsonNode.ParseAsync( - await bucketsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - JsonArray? buckets = bucketsBody?["data"]?.AsArray(); - - if (buckets is null) - { - return; - } - - // Find the bucket that matches the endpoint the frontend sent. - - foreach (JsonNode? bucket in buckets) - { - if (bucket is null) - { - continue; - } - - string? bucketEndpoint = bucket["endpoint"]?.GetValue(); - - if (string.Equals(bucketEndpoint, endpoint, StringComparison.OrdinalIgnoreCase)) - { - // Found the matching bucket — get its full details (including secrets). - - Guid bucketId = bucket["id"]!.GetValue(); - HttpResponseMessage detailResponse = await clustersClient.GetAsync( - $"/api/clusters/{clusterId}/storage/buckets/{bucketId}", ct); - - if (!detailResponse.IsSuccessStatusCode) - { - return; - } - - JsonNode? detailBody = await JsonNode.ParseAsync( - await detailResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - string? accessKey = detailBody?["data"]?["accessKey"]?.GetValue(); - string? secretKey = detailBody?["data"]?["secretKey"]?.GetValue(); - - if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey)) - { - body["s3AccessKey"] = accessKey; - body["s3SecretKey"] = secretKey; - } - - return; - } - } - } -} diff --git a/src/EntKube.Web/Components/Provisioning/PrometheusProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/PrometheusProxyEndpoints.cs deleted file mode 100644 index bcfc320..0000000 --- a/src/EntKube.Web/Components/Provisioning/PrometheusProxyEndpoints.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for Prometheus stack management. Forwards requests from -/// the Blazor frontend to the Provisioning microservice's Prometheus endpoints. -/// -public static class PrometheusProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/prometheus") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/prometheus — list all Prometheus stacks - group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync("/api/prometheus", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/prometheus/cluster/{clusterId} - group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/prometheus/cluster/{clusterId}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/prometheus/environment/{environmentId} - group.MapGet("environment/{environmentId:guid}", async (Guid environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/prometheus/environment/{environmentId}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // GET /api/prometheus/{id} - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/prometheus/{id}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // POST /api/prometheus — create a new Prometheus stack - group.MapPost("", async (CreatePrometheusStackProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsJsonAsync("/api/prometheus", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/prometheus/{id} - group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/prometheus/{id}", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/prometheus/{id}/config - group.MapPut("{id:guid}/config", async (Guid id, ConfigurePrometheusProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/config", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/prometheus/{id}/alertmanager - group.MapPut("{id:guid}/alertmanager", async (Guid id, ConfigureAlertmanagerProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/alertmanager", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // PUT /api/prometheus/{id}/ingress - group.MapPut("{id:guid}/ingress", async (Guid id, ConfigureIngressProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/ingress", request, ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - - // DELETE /api/prometheus/{id}/ingress - group.MapDelete("{id:guid}/ingress", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/prometheus/{id}/ingress", ct); - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - }); - } -} - -// ─── Proxy Request DTOs ───────────────────────────────────────────── - -public record CreatePrometheusStackProxyRequest(Guid ClusterId, Guid EnvironmentId, string Name, string Namespace); - -public record ConfigurePrometheusProxyRequest(string? Retention, int? Replicas, string? StorageSize, string? RetentionSize); - -public record ConfigureAlertmanagerProxyRequest(bool? Enabled, int? Replicas); - -public record ConfigureIngressProxyRequest( - bool Enabled, string Provider, - string? PrometheusHostname, string? AlertmanagerHostname, - bool TlsEnabled, string TlsCertificateMode = "Manual", - string? TlsSecretName = null, string? ClusterIssuer = null); diff --git a/src/EntKube.Web/Components/Provisioning/RedisClusterProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/RedisClusterProxyEndpoints.cs deleted file mode 100644 index 238aeec..0000000 --- a/src/EntKube.Web/Components/Provisioning/RedisClusterProxyEndpoints.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System.Text; -using System.Text.Json.Nodes; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for Redis cluster management. The Blazor frontend calls -/// these endpoints, and the BFF forwards requests to the Provisioning -/// microservice's /api/redis-clusters routes. -/// -/// The frontend never sends kubeConfig — the BFF resolves it server-side by -/// calling the Clusters service's /api/clusters/{id}/credentials endpoint, -/// then injects it into the request before forwarding to Provisioning. -/// -public static class RedisClusterProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/redis-clusters") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/redis-clusters — list clusters, optionally filtered by clusterId. - - group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - string url = clusterId.HasValue - ? $"/api/redis-clusters?clusterId={clusterId.Value}" - : "/api/redis-clusters"; - - HttpResponseMessage response = await client.GetAsync(url, ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // GET /api/redis-clusters/{id} — get a single cluster. - - group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.GetAsync($"/api/redis-clusters/{id}", ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/redis-clusters — create a new Redis instance. - // Frontend sends clusterId + config. BFF injects kubeConfig/contextName. - - group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/redis-clusters", "POST", ct); - }); - - // POST /api/redis-clusters/adopt — discover and adopt existing instances. - - group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, "/api/redis-clusters/adopt", "POST", ct); - }); - - // POST /api/redis-clusters/{id}/upgrade — initiate version upgrade. - - group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/upgrade", "POST", ct); - }); - - // PUT /api/redis-clusters/{id}/configure — reconfigure settings. - - group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/configure", "PUT", ct); - }); - - // GET /api/redis-clusters/{id}/health — live health from K8s. - // The BFF resolves credentials server-side and forwards to Provisioning - // via POST (kubeConfig is too large for query strings). - - group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/redis-clusters/{id}/health", ct); - }); - - // POST /api/redis-clusters/{id}/restart — rolling restart. - - group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/restart", "POST", ct); - }); - - // POST /api/redis-clusters/{id}/failover — controlled switchover. - - group.MapPost("{id:guid}/failover", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/failover", "POST", ct); - }); - - // DELETE /api/redis-clusters/{id} — delete a Redis instance. - - group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, $"/api/redis-clusters/{id}", ct, "DELETE"); - }); - - // GET /api/redis-clusters/available-versions — list supported Redis versions. - - group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await ForwardGetWithCredentials( - httpClientFactory, clusterId, "/api/redis-clusters/available-versions", ct); - }); - } - - /// - /// Forwards a request to Provisioning by injecting kubeConfig+contextName into - /// the body as a POST. This avoids putting large kubeConfig data into query - /// strings, which can exceed URL length limits. - /// - private static async Task ForwardGetWithCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, - string provisioningPath, CancellationToken ct, string httpMethod = "POST") - { - ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - JsonNode body = new JsonObject - { - ["kubeConfig"] = creds.KubeConfig, - ["contextName"] = creds.ContextName - }; - - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "DELETE" - ? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string error = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(error, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - /// - /// Reads the request body, resolves kubeConfig from the Clusters service using - /// the clusterId in the body, injects the credentials, and forwards to Provisioning. - /// - private static async Task ForwardWithCredentials( - HttpRequest request, IHttpClientFactory httpClientFactory, - string provisioningPath, string httpMethod, CancellationToken ct) - { - JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct); - - if (body is null) - { - return Results.BadRequest("Request body is required."); - } - - // Resolve kubeConfig from the Clusters service. - - Guid? clusterId = body["clusterId"]?.GetValue(); - - if (clusterId is null) - { - return Results.BadRequest("clusterId is required."); - } - - ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct); - - if (creds is null) - { - return Results.BadRequest("Could not resolve cluster credentials."); - } - - body["kubeConfig"] = creds.KubeConfig; - body["contextName"] = creds.ContextName; - - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); - - HttpResponseMessage response = httpMethod == "PUT" - ? await client.PutAsync(provisioningPath, content, ct) - : await client.PostAsync(provisioningPath, content, ct); - - if (!response.IsSuccessStatusCode) - { - string errorBody = await response.Content.ReadAsStringAsync(ct); - return Results.Problem(errorBody, statusCode: (int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - } - - /// - /// Fetches kubeConfig and contextName from the Clusters service for a given - /// Kubernetes cluster ID. This is a server-to-server call — the frontend - /// never sees the kubeConfig. - /// - private static async Task GetClusterCredentials( - IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct) - { - HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi"); - HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct); - - if (!credsResponse.IsSuccessStatusCode) - { - return null; - } - - JsonNode? credsBody = await JsonNode.ParseAsync( - await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct); - - string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue(); - string? contextName = credsBody?["data"]?["contextName"]?.GetValue(); - - if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName)) - { - return null; - } - - return new ClusterCreds(kubeConfig, contextName); - } - - private record ClusterCreds(string KubeConfig, string ContextName); -} diff --git a/src/EntKube.Web/Components/Provisioning/ServicesProxyEndpoints.cs b/src/EntKube.Web/Components/Provisioning/ServicesProxyEndpoints.cs deleted file mode 100644 index 9372a2a..0000000 --- a/src/EntKube.Web/Components/Provisioning/ServicesProxyEndpoints.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Net.Http.Json; - -namespace EntKube.Web.Components.Provisioning; - -/// -/// BFF proxy endpoints for service provisioning. The Blazor frontend calls these -/// endpoints, and the BFF forwards requests to the Provisioning microservice. -/// -public static class ServicesProxyEndpoints -{ - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/services") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/services — list all services (optionally filtered by clusterId) - group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - string url = clusterId.HasValue - ? $"/api/services?clusterId={clusterId.Value}" - : "/api/services"; - - HttpResponseMessage response = await client.GetAsync(url, ct); - response.EnsureSuccessStatusCode(); - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/services — provision a new service - group.MapPost("", async (ProvisionServiceRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsJsonAsync("/api/services", request, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - - // POST /api/services/{id}/decommission — request decommissioning - group.MapPost("{id:guid}/decommission", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - HttpClient client = httpClientFactory.CreateClient("ProvisioningApi"); - HttpResponseMessage response = await client.PostAsync($"/api/services/{id}/decommission", null, ct); - - if (!response.IsSuccessStatusCode) - { - return Results.StatusCode((int)response.StatusCode); - } - - return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json"); - }); - } -} - -public record ProvisionServiceRequest(Guid ClusterId, string ServiceType, string Name, string Namespace); diff --git a/src/EntKube.Web.Client/Routes.razor b/src/EntKube.Web/Components/Routes.razor similarity index 68% rename from src/EntKube.Web.Client/Routes.razor rename to src/EntKube.Web/Components/Routes.razor index d8121a8..32e35ee 100644 --- a/src/EntKube.Web.Client/Routes.razor +++ b/src/EntKube.Web/Components/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/src/EntKube.Web/Components/Secrets/SecretsProxyEndpoints.cs b/src/EntKube.Web/Components/Secrets/SecretsProxyEndpoints.cs deleted file mode 100644 index 94d15a5..0000000 --- a/src/EntKube.Web/Components/Secrets/SecretsProxyEndpoints.cs +++ /dev/null @@ -1,180 +0,0 @@ -namespace EntKube.Web.Components.Secrets; - -/// -/// BFF proxy endpoints for the Secrets microservice. All vault operations -/// are tenant-scoped — the frontend passes the tenantId in the URL path. -/// -public static class SecretsProxyEndpoints -{ - private static async Task ForwardResponseAsync(HttpResponseMessage response, CancellationToken ct) - { - string body = await response.Content.ReadAsStringAsync(ct); - return Results.Text(body, "application/json", statusCode: (int)response.StatusCode); - } - - private static async Task SafeProxyAsync(Func> action) - { - try - { - return await action(); - } - catch (HttpRequestException) - { - return Results.Json( - new { Success = false, Error = "Secrets service is unreachable. Make sure it is running on the configured port." }, - statusCode: 502); - } - } - - public static void Map(IEndpointRouteBuilder app) - { - RouteGroupBuilder group = app.MapGroup("/api/vault") - .RequireAuthorization() - .DisableAntiforgery(); - - // GET /api/vault/{tenantId}/status — tenant vault status - group.MapGet("{tenantId:guid}/status", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/status", ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/{tenantId}/init — initialize tenant vault - group.MapPost("{tenantId:guid}/init", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/init", content, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/{tenantId}/seal — seal tenant vault - group.MapPost("{tenantId:guid}/seal", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/seal", null, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/seal — seal all tenants - group.MapPost("seal", async (IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.PostAsync("/api/vault/seal", null, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/{tenantId}/unseal — provide unseal share - group.MapPost("{tenantId:guid}/unseal", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/unseal", content, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // GET /api/vault/{tenantId}/secrets — list secret paths - group.MapGet("{tenantId:guid}/secrets", async (Guid tenantId, string? prefix, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - string url = string.IsNullOrEmpty(prefix) - ? $"/api/vault/{tenantId}/secrets" - : $"/api/vault/{tenantId}/secrets?prefix={Uri.EscapeDataString(prefix)}"; - HttpResponseMessage response = await client.GetAsync(url, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // GET /api/vault/{tenantId}/secrets/{path} — get a secret - group.MapGet("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/secrets/{path}", ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/{tenantId}/secrets/{path} — put a secret - group.MapPost("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/secrets/{path}", content, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // DELETE /api/vault/{tenantId}/secrets/{path} — delete a secret - group.MapDelete("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.DeleteAsync($"/api/vault/{tenantId}/secrets/{path}", ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/{tenantId}/tokens — create a service token - group.MapPost("{tenantId:guid}/tokens", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/tokens", content, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // GET /api/vault/{tenantId}/tokens — list service tokens - group.MapGet("{tenantId:guid}/tokens", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/tokens", ct); - return await ForwardResponseAsync(response, ct); - }); - }); - - // POST /api/vault/tokens/revoke — revoke a token (global, token hash is unique) - group.MapPost("tokens/revoke", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) => - { - return await SafeProxyAsync(async () => - { - HttpClient client = httpClientFactory.CreateClient("SecretsApi"); - StreamContent content = new(request.Body); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - HttpResponseMessage response = await client.PostAsync("/api/vault/tokens/revoke", content, ct); - return await ForwardResponseAsync(response, ct); - }); - }); - } -} diff --git a/src/EntKube.Web/Components/_Imports.razor b/src/EntKube.Web/Components/_Imports.razor index cce9039..2301c6c 100644 --- a/src/EntKube.Web/Components/_Imports.razor +++ b/src/EntKube.Web/Components/_Imports.razor @@ -9,5 +9,5 @@ @using Microsoft.JSInterop @using EntKube.Web @using EntKube.Web.Client -@using EntKube.Web.Client.Layout @using EntKube.Web.Components +@using EntKube.Web.Components.Layout diff --git a/src/EntKube.Web/Data/App.cs b/src/EntKube.Web/Data/App.cs new file mode 100644 index 0000000..ee684b2 --- /dev/null +++ b/src/EntKube.Web/Data/App.cs @@ -0,0 +1,25 @@ +namespace EntKube.Web.Data; + +/// +/// An app represents a software application owned by a customer. Apps can be +/// deployed to one or many environments via the AppEnvironment join entity. +/// +public class App +{ + public Guid Id { get; set; } + + public Guid CustomerId { get; set; } + + /// + /// The application name. Must be unique within a customer. + /// + public required string Name { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Customer Customer { get; set; } = null!; + public ICollection AppEnvironments { get; set; } = []; + public ICollection Secrets { get; set; } = []; + public ICollection Deployments { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/AppDeployment.cs b/src/EntKube.Web/Data/AppDeployment.cs new file mode 100644 index 0000000..472dba7 --- /dev/null +++ b/src/EntKube.Web/Data/AppDeployment.cs @@ -0,0 +1,89 @@ +namespace EntKube.Web.Data; + +/// +/// An app deployment represents a deployable unit targeting a specific cluster +/// and namespace. Think of it like an ArgoCD Application — it describes what +/// should be deployed, where, and tracks whether the live state matches. +/// +/// A deployment can be defined three ways: +/// - Manual: structured form (containers, services, PVCs) → generated manifests +/// - Yaml: raw K8s YAML pasted or uploaded +/// - HelmChart: a Helm chart with repo, name, version, and dynamic values +/// +/// Regardless of type, the platform tracks the deployment's sync and health +/// status by comparing desired state to live cluster state. +/// +public class AppDeployment +{ + public Guid Id { get; set; } + + public Guid AppId { get; set; } + + /// + /// A human-friendly name for this deployment (e.g. "billing-api-prod"). + /// Must be unique within an app. + /// + public required string Name { get; set; } + + /// + /// How this deployment is defined — Manual form, raw YAML, or Helm chart. + /// + public DeploymentType Type { get; set; } + + // ── Target ── + + /// + /// The environment this deployment targets (e.g. Production). + /// + public Guid EnvironmentId { get; set; } + + /// + /// The cluster within that environment where resources will be applied. + /// + public Guid ClusterId { get; set; } + + /// + /// The Kubernetes namespace for all resources in this deployment. + /// + public required string Namespace { get; set; } + + // ── Status (ArgoCD-style) ── + + public SyncStatus SyncStatus { get; set; } = SyncStatus.Unknown; + public HealthStatus HealthStatus { get; set; } = HealthStatus.Unknown; + public string? StatusMessage { get; set; } + public DateTime? LastSyncedAt { get; set; } + + // ── Helm-specific fields (only used when Type == HelmChart) ── + + /// + /// The Helm chart repository URL (e.g. "https://charts.bitnami.com/bitnami"). + /// + public string? HelmRepoUrl { get; set; } + + /// + /// The chart name within the repository (e.g. "postgresql"). + /// + public string? HelmChartName { get; set; } + + /// + /// The chart version to deploy (e.g. "15.5.0"). + /// + public string? HelmChartVersion { get; set; } + + /// + /// The Helm values as YAML text. These override the chart's default values. + /// Stored as free-form YAML so any chart's values can be represented. + /// + public string? HelmValues { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public App App { get; set; } = null!; + public Environment Environment { get; set; } = null!; + public KubernetesCluster Cluster { get; set; } = null!; + public ICollection Manifests { get; set; } = []; + public ICollection Resources { get; set; } = []; + public ICollection StorageBindings { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/AppEnvironment.cs b/src/EntKube.Web/Data/AppEnvironment.cs new file mode 100644 index 0000000..3275f78 --- /dev/null +++ b/src/EntKube.Web/Data/AppEnvironment.cs @@ -0,0 +1,19 @@ +namespace EntKube.Web.Data; + +/// +/// Join entity linking an app to an environment. This represents the +/// many-to-many relationship: an app can be deployed to multiple environments, +/// and an environment can host multiple apps. +/// +public class AppEnvironment +{ + public Guid AppId { get; set; } + + public Guid EnvironmentId { get; set; } + + public DateTime LinkedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public App App { get; set; } = null!; + public Environment Environment { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/ApplicationDbContext.cs b/src/EntKube.Web/Data/ApplicationDbContext.cs index 45913f4..8f18489 100644 --- a/src/EntKube.Web/Data/ApplicationDbContext.cs +++ b/src/EntKube.Web/Data/ApplicationDbContext.cs @@ -1,8 +1,498 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace EntKube.Web.Data; -public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) +/// +/// The main database context for EntKube, holding Identity tables and all +/// application entities. This is the base context — provider-specific +/// subclasses exist for PostgreSQL and SQL Server so EF Core can generate +/// provider-appropriate migrations independently. +/// +public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) +{ + public DbSet Tenants => Set(); + public DbSet TenantRoles => Set(); + public DbSet TenantMemberships => Set(); + public DbSet Groups => Set(); + public DbSet GroupMemberships => Set(); + public DbSet Environments => Set(); + public DbSet Customers => Set(); + public DbSet Apps => Set(); + public DbSet AppEnvironments => Set(); + public DbSet KubernetesClusters => Set(); + public DbSet SecretVaults => Set(); + public DbSet VaultSecrets => Set(); + public DbSet ClusterComponents => Set(); + public DbSet ExternalRoutes => Set(); + public DbSet StorageLinks => Set(); + public DbSet OpenStackConnections => Set(); + public DbSet StorageBindings => Set(); + public DbSet AppDeployments => Set(); + public DbSet DeploymentManifests => Set(); + public DbSet DeploymentResources => Set(); + public DbSet CustomerAccesses => Set(); + public DbSet CnpgClusters => Set(); + public DbSet CnpgDatabases => Set(); + public DbSet CnpgBackups => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + // Suppress the PendingModelChangesWarning during development. + // The model may temporarily drift from the snapshot while iterating + // on entity design — this prevents the app from failing to start. + + optionsBuilder.ConfigureWarnings(w => + w.Ignore(RelationalEventId.PendingModelChangesWarning)); + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // Tenant — the slug must be unique across all tenants since it's + // used as the URL-safe identifier in routes and API calls. + + builder.Entity(entity => + { + entity.HasKey(t => t.Id); + entity.HasIndex(t => t.Slug).IsUnique(); + entity.Property(t => t.Name).HasMaxLength(200).IsRequired(); + entity.Property(t => t.Slug).HasMaxLength(100).IsRequired(); + }); + + // TenantRole — each role name must be unique within its tenant. + // A tenant owns its roles; deleting a tenant cascades to its roles. + + builder.Entity(entity => + { + entity.HasKey(r => r.Id); + entity.HasIndex(r => new { r.TenantId, r.Name }).IsUnique(); + entity.Property(r => r.Name).HasMaxLength(100).IsRequired(); + + entity.HasOne(r => r.Tenant) + .WithMany(t => t.Roles) + .HasForeignKey(r => r.TenantId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // TenantMembership — composite key on (UserId, TenantId) ensures + // a user can only have one membership per tenant. The role FK tells + // us what they can do in that tenant. + + builder.Entity(entity => + { + entity.HasKey(m => new { m.UserId, m.TenantId }); + + entity.HasOne(m => m.User) + .WithMany() + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(m => m.Tenant) + .WithMany(t => t.Memberships) + .HasForeignKey(m => m.TenantId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(m => m.Role) + .WithMany(r => r.Memberships) + .HasForeignKey(m => m.RoleId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // Group — belongs to a tenant. Name should be unique within a tenant. + + builder.Entity(entity => + { + entity.HasKey(g => g.Id); + entity.HasIndex(g => new { g.TenantId, g.Name }).IsUnique(); + entity.Property(g => g.Name).HasMaxLength(200).IsRequired(); + + entity.HasOne(g => g.Tenant) + .WithMany(t => t.Groups) + .HasForeignKey(g => g.TenantId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // GroupMembership — composite key on (UserId, GroupId) prevents + // a user from being added to the same group twice. + + builder.Entity(entity => + { + entity.HasKey(gm => new { gm.UserId, gm.GroupId }); + + entity.HasOne(gm => gm.User) + .WithMany() + .HasForeignKey(gm => gm.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(gm => gm.Group) + .WithMany(g => g.Memberships) + .HasForeignKey(gm => gm.GroupId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Environment — belongs to a tenant. Name must be unique within a tenant. + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique(); + entity.Property(e => e.Name).HasMaxLength(100).IsRequired(); + + entity.HasOne(e => e.Tenant) + .WithMany(t => t.Environments) + .HasForeignKey(e => e.TenantId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // Customer — belongs to a tenant. Name must be unique within a tenant. + + builder.Entity(entity => + { + entity.HasKey(c => c.Id); + entity.HasIndex(c => new { c.TenantId, c.Name }).IsUnique(); + entity.Property(c => c.Name).HasMaxLength(200).IsRequired(); + + entity.HasOne(c => c.Tenant) + .WithMany(t => t.Customers) + .HasForeignKey(c => c.TenantId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // App — belongs to a customer. Name must be unique within a customer. + + builder.Entity(entity => + { + entity.HasKey(a => a.Id); + entity.HasIndex(a => new { a.CustomerId, a.Name }).IsUnique(); + entity.Property(a => a.Name).HasMaxLength(200).IsRequired(); + + entity.HasOne(a => a.Customer) + .WithMany(c => c.Apps) + .HasForeignKey(a => a.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // AppEnvironment — many-to-many join between App and Environment. + // Composite key prevents duplicate links. + + builder.Entity(entity => + { + entity.HasKey(ae => new { ae.AppId, ae.EnvironmentId }); + + entity.HasOne(ae => ae.App) + .WithMany(a => a.AppEnvironments) + .HasForeignKey(ae => ae.AppId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(ae => ae.Environment) + .WithMany(e => e.AppEnvironments) + .HasForeignKey(ae => ae.EnvironmentId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // KubernetesCluster — belongs to a tenant and an environment. + // Name must be unique within a tenant. + + builder.Entity(entity => + { + entity.HasKey(c => c.Id); + entity.HasIndex(c => new { c.TenantId, c.Name }).IsUnique(); + entity.Property(c => c.Name).HasMaxLength(200).IsRequired(); + entity.Property(c => c.ApiServerUrl).HasMaxLength(500).IsRequired(); + + entity.HasOne(c => c.Tenant) + .WithMany(t => t.KubernetesClusters) + .HasForeignKey(c => c.TenantId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(c => c.Environment) + .WithMany(e => e.KubernetesClusters) + .HasForeignKey(c => c.EnvironmentId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // SecretVault — one vault per tenant. Stores the sealed Data Encryption Key. + // A tenant can have at most one vault (1:1 relationship). + + builder.Entity(entity => + { + entity.HasKey(v => v.Id); + entity.HasIndex(v => v.TenantId).IsUnique(); + + entity.HasOne(v => v.Tenant) + .WithOne(t => t.Vault) + .HasForeignKey(v => v.TenantId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // VaultSecret — an individual encrypted secret. Scoped to either an App + // or a ClusterComponent. Name must be unique within its scope. + + builder.Entity(entity => + { + entity.HasKey(s => s.Id); + entity.Property(s => s.Name).HasMaxLength(200).IsRequired(); + entity.Property(s => s.KubernetesSecretName).HasMaxLength(253); + entity.Property(s => s.KubernetesNamespace).HasMaxLength(63); + + entity.HasIndex(s => new { s.VaultId, s.AppId, s.Name }) + .IsUnique() + .HasFilter(null); + + entity.HasIndex(s => new { s.VaultId, s.ComponentId, s.Name }) + .IsUnique() + .HasFilter(null); + + entity.HasOne(s => s.Vault) + .WithMany(v => v.Secrets) + .HasForeignKey(s => s.VaultId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(s => s.App) + .WithMany(a => a.Secrets) + .HasForeignKey(s => s.AppId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(s => s.Component) + .WithMany(c => c.Secrets) + .HasForeignKey(s => s.ComponentId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(s => s.CnpgDatabase) + .WithMany() + .HasForeignKey(s => s.CnpgDatabaseId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // ClusterComponent — a deployable unit (Helm chart, operator, etc.) on a cluster. + // Name must be unique within a cluster. + + builder.Entity(entity => + { + entity.HasKey(c => c.Id); + entity.HasIndex(c => new { c.ClusterId, c.Name }).IsUnique(); + entity.Property(c => c.Name).HasMaxLength(200).IsRequired(); + entity.Property(c => c.ComponentType).HasMaxLength(50).IsRequired(); + + entity.HasOne(c => c.Cluster) + .WithMany(k => k.Components) + .HasForeignKey(c => c.ClusterId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // ExternalRoute — exposes a component via Gateway API HTTPRoute. + // Hostname must be unique within a cluster (enforced via component→cluster). + + builder.Entity(entity => + { + entity.HasKey(r => r.Id); + entity.Property(r => r.Hostname).HasMaxLength(253).IsRequired(); + entity.Property(r => r.ServiceName).HasMaxLength(200); + entity.Property(r => r.PathPrefix).HasMaxLength(200); + entity.Property(r => r.ClusterIssuerName).HasMaxLength(200); + entity.Property(r => r.GatewayName).HasMaxLength(200); + entity.Property(r => r.GatewayNamespace).HasMaxLength(63); + entity.Property(r => r.TlsMode).HasConversion().HasMaxLength(20); + + entity.HasOne(r => r.Component) + .WithMany(c => c.ExternalRoutes) + .HasForeignKey(r => r.ComponentId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // AppDeployment — a deployable unit targeting a specific cluster and namespace. + // Name must be unique within an app. Stores sync/health status like ArgoCD. + + builder.Entity(entity => + { + entity.HasKey(d => d.Id); + entity.HasIndex(d => new { d.AppId, d.Name }).IsUnique(); + entity.Property(d => d.Name).HasMaxLength(200).IsRequired(); + entity.Property(d => d.Namespace).HasMaxLength(63).IsRequired(); + entity.Property(d => d.Type).HasConversion().HasMaxLength(20); + entity.Property(d => d.SyncStatus).HasConversion().HasMaxLength(20); + entity.Property(d => d.HealthStatus).HasConversion().HasMaxLength(20); + entity.Property(d => d.HelmRepoUrl).HasMaxLength(500); + entity.Property(d => d.HelmChartName).HasMaxLength(200); + entity.Property(d => d.HelmChartVersion).HasMaxLength(50); + + entity.HasOne(d => d.App) + .WithMany(a => a.Deployments) + .HasForeignKey(d => d.AppId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(d => d.Environment) + .WithMany() + .HasForeignKey(d => d.EnvironmentId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(d => d.Cluster) + .WithMany() + .HasForeignKey(d => d.ClusterId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // DeploymentManifest — an individual K8s YAML document within a deployment. + // Applied in SortOrder sequence. Name is informational, not necessarily unique. + + builder.Entity(entity => + { + entity.HasKey(m => m.Id); + entity.Property(m => m.Kind).HasMaxLength(100).IsRequired(); + entity.Property(m => m.Name).HasMaxLength(253).IsRequired(); + + entity.HasOne(m => m.Deployment) + .WithMany(d => d.Manifests) + .HasForeignKey(m => m.DeploymentId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // DeploymentResource — a tracked live resource in the cluster (ArgoCD-style + // resource tree). Resources form a parent-child hierarchy for tree rendering. + + builder.Entity(entity => + { + entity.HasKey(r => r.Id); + entity.Property(r => r.Group).HasMaxLength(100).IsRequired(); + entity.Property(r => r.Version).HasMaxLength(20).IsRequired(); + entity.Property(r => r.Kind).HasMaxLength(100).IsRequired(); + entity.Property(r => r.Name).HasMaxLength(253).IsRequired(); + entity.Property(r => r.Namespace).HasMaxLength(63); + entity.Property(r => r.SyncStatus).HasConversion().HasMaxLength(20); + entity.Property(r => r.HealthStatus).HasConversion().HasMaxLength(20); + + entity.HasOne(r => r.Deployment) + .WithMany(d => d.Resources) + .HasForeignKey(r => r.DeploymentId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(r => r.ParentResource) + .WithMany(r => r.ChildResources) + .HasForeignKey(r => r.ParentResourceId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // CustomerAccess — composite key on (UserId, CustomerId) ensures + // a user gets exactly one access entry per customer. The Role enum + // controls what they can do (Viewer, Operator, Admin). + + builder.Entity(entity => + { + entity.HasKey(ca => new { ca.UserId, ca.CustomerId }); + + entity.Property(ca => ca.Role) + .HasConversion() + .HasMaxLength(20); + + entity.HasOne(ca => ca.User) + .WithMany() + .HasForeignKey(ca => ca.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(ca => ca.Customer) + .WithMany() + .HasForeignKey(ca => ca.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // StorageBinding — connects a StorageLink to a workload (AppDeployment or + // ClusterComponent). The platform syncs storage credentials to a K8s Secret + // in the workload's namespace so pods can consume them. + + builder.Entity(entity => + { + entity.HasKey(b => b.Id); + entity.Property(b => b.KubernetesSecretName).HasMaxLength(253).IsRequired(); + + entity.HasOne(b => b.StorageLink) + .WithMany(s => s.StorageBindings) + .HasForeignKey(b => b.StorageLinkId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(b => b.AppDeployment) + .WithMany(d => d.StorageBindings) + .HasForeignKey(b => b.AppDeploymentId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(b => b.Component) + .WithMany(c => c.StorageBindings) + .HasForeignKey(b => b.ComponentId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasKey(c => c.Id); + entity.Property(c => c.Name).HasMaxLength(63).IsRequired(); + entity.Property(c => c.Namespace).HasMaxLength(63).IsRequired(); + entity.Property(c => c.PostgresVersion).HasMaxLength(10).IsRequired(); + entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired(); + entity.Property(c => c.BackupSchedule).HasMaxLength(100); + + entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique(); + + entity.HasOne(c => c.Tenant) + .WithMany() + .HasForeignKey(c => c.TenantId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(c => c.KubernetesCluster) + .WithMany() + .HasForeignKey(c => c.KubernetesClusterId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(c => c.StorageLink) + .WithMany() + .HasForeignKey(c => c.StorageLinkId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.HasKey(d => d.Id); + entity.Property(d => d.Name).HasMaxLength(63).IsRequired(); + entity.Property(d => d.Owner).HasMaxLength(63).IsRequired(); + + entity.HasIndex(d => new { d.CnpgClusterId, d.Name }).IsUnique(); + + entity.HasOne(d => d.CnpgCluster) + .WithMany(c => c.Databases) + .HasForeignKey(d => d.CnpgClusterId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.HasKey(b => b.Id); + entity.Property(b => b.Name).HasMaxLength(253).IsRequired(); + + entity.HasIndex(b => new { b.CnpgClusterId, b.Name }).IsUnique(); + + entity.HasOne(b => b.CnpgCluster) + .WithMany(c => c.Backups) + .HasForeignKey(b => b.CnpgClusterId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} + +/// +/// PostgreSQL-specific context. Used only for generating and applying +/// PostgreSQL migrations. Shares the same model as the base context. +/// +public class PostgresApplicationDbContext(DbContextOptions options) + : ApplicationDbContext(options) +{ +} + +/// +/// SQL Server-specific context. Used only for generating and applying +/// SQL Server migrations. Shares the same model as the base context. +/// +public class SqlServerApplicationDbContext(DbContextOptions options) + : ApplicationDbContext(options) { } diff --git a/src/EntKube.Web/Data/ClusterComponent.cs b/src/EntKube.Web/Data/ClusterComponent.cs new file mode 100644 index 0000000..575c4c7 --- /dev/null +++ b/src/EntKube.Web/Data/ClusterComponent.cs @@ -0,0 +1,87 @@ +namespace EntKube.Web.Data; + +/// +/// Lifecycle status of a cluster component. Tracks whether the component +/// has been installed to the target cluster and its current operational state. +/// +public enum ComponentStatus +{ + /// Component is registered but not yet installed on the cluster. + NotInstalled, + /// Helm install/upgrade is in progress. + Installing, + /// Component is installed and running on the cluster. + Installed, + /// Last install/upgrade attempt failed. + Failed, + /// Helm uninstall is in progress. + Uninstalling +} + +/// +/// A component deployed to a Kubernetes cluster. Components represent Helm charts, +/// deployments, or other installable units managed by the platform. Each component +/// can have secrets in the vault that are integrated with its Helm values or +/// environment configuration during deployment. +/// +public class ClusterComponent +{ + public Guid Id { get; set; } + + public Guid ClusterId { get; set; } + + /// + /// Human-friendly name (e.g. "minio", "cnpg-cluster", "keycloak"). + /// Must be unique within a cluster. + /// + public required string Name { get; set; } + + /// + /// The type of component. Will be refined later with a proper enum/value set. + /// Examples: "HelmChart", "Deployment", "StatefulSet", "Operator". + /// + public required string ComponentType { get; set; } + + /// + /// Optional JSON configuration specific to this component type. + /// For kube-prometheus-stack: {"namespace","serviceName","servicePort"}. + /// + public string? Configuration { get; set; } + + // ── Lifecycle fields ── + + /// Current lifecycle status of this component on the cluster. + public ComponentStatus Status { get; set; } = ComponentStatus.NotInstalled; + + /// Target namespace where the component is installed. + public string? Namespace { get; set; } + + /// Helm repository URL (e.g. "https://prometheus-community.github.io/helm-charts"). + public string? HelmRepoUrl { get; set; } + + /// Helm chart name (e.g. "kube-prometheus-stack"). + public string? HelmChartName { get; set; } + + /// Helm chart version (e.g. "65.1.0"). Null means latest. + public string? HelmChartVersion { get; set; } + + /// Helm release name on the cluster. Defaults to component Name if not set. + public string? ReleaseName { get; set; } + + /// Custom Helm values in YAML format, applied with --values during install/upgrade. + public string? HelmValues { get; set; } + + /// Error message from the last failed operation, if any. + public string? LastError { get; set; } + + /// When the component was last installed or upgraded. + public DateTime? InstalledAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public KubernetesCluster Cluster { get; set; } = null!; + public ICollection Secrets { get; set; } = []; + public ICollection ExternalRoutes { get; set; } = []; + public ICollection StorageBindings { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/CnpgBackup.cs b/src/EntKube.Web/Data/CnpgBackup.cs new file mode 100644 index 0000000..c5f777d --- /dev/null +++ b/src/EntKube.Web/Data/CnpgBackup.cs @@ -0,0 +1,74 @@ +namespace EntKube.Web.Data; + +/// +/// Type of CNPG backup — on-demand (user-triggered) or scheduled (cron). +/// +public enum CnpgBackupType +{ + OnDemand, + Scheduled +} + +/// +/// Status of a CNPG backup operation. +/// +public enum CnpgBackupStatus +{ + Running, + Completed, + Failed +} + +/// +/// A backup record for a managed CNPG cluster. Represents a Backup CR +/// in Kubernetes that triggers Barman to take a base backup to the +/// configured S3 bucket. Combined with continuous WAL archiving, this +/// enables point-in-time recovery (PITR) to any moment between backups. +/// +public class CnpgBackup +{ + public Guid Id { get; set; } + + /// + /// The CNPG cluster this backup belongs to. + /// + public Guid CnpgClusterId { get; set; } + + /// + /// The Kubernetes Backup resource name (e.g. "my-cluster-20260517-120000"). + /// + public required string Name { get; set; } + + /// + /// Whether this was triggered manually or by a schedule. + /// + public CnpgBackupType Type { get; set; } = CnpgBackupType.OnDemand; + + /// + /// Current status of the backup. + /// + public CnpgBackupStatus Status { get; set; } = CnpgBackupStatus.Running; + + /// + /// When the backup started. + /// + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the backup completed (null if still running or failed before completion). + /// + public DateTime? CompletedAt { get; set; } + + /// + /// Backup size in bytes (reported by Barman after completion). + /// + public long? SizeBytes { get; set; } + + /// + /// Error message if the backup failed. + /// + public string? LastError { get; set; } + + // Navigation + public CnpgCluster CnpgCluster { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/CnpgCluster.cs b/src/EntKube.Web/Data/CnpgCluster.cs new file mode 100644 index 0000000..5abdaae --- /dev/null +++ b/src/EntKube.Web/Data/CnpgCluster.cs @@ -0,0 +1,99 @@ +namespace EntKube.Web.Data; + +/// +/// The lifecycle status of a managed CNPG cluster. Tracks where the cluster +/// is in the provisioning/operational/teardown cycle. +/// +public enum CnpgClusterStatus +{ + Creating, + Running, + Upgrading, + Restoring, + Failed, + Deleting +} + +/// +/// A managed CloudNativePG cluster that EntKube provisions and controls. +/// Each cluster lives on a Kubernetes cluster where the CNPG operator is installed. +/// Optionally backed by a StorageLink (S3 bucket) for Barman backups and PITR. +/// +/// The cluster is represented as a CNPG Cluster CRD in Kubernetes. +/// EntKube owns the full lifecycle: create, upgrade, backup, restore, delete. +/// +public class CnpgCluster +{ + public Guid Id { get; set; } + + /// + /// The tenant that owns this cluster. + /// + public Guid TenantId { get; set; } + + /// + /// The Kubernetes cluster where this CNPG cluster runs. + /// Must have the cloudnative-pg operator installed. + /// + public Guid KubernetesClusterId { get; set; } + + /// + /// The name of the CNPG Cluster resource in Kubernetes (metadata.name). + /// Lowercase, DNS-safe, max 63 chars. + /// + public required string Name { get; set; } + + /// + /// The Kubernetes namespace where the cluster lives. + /// + public required string Namespace { get; set; } + + /// + /// The major PostgreSQL version (e.g. "18", "17", "16"). + /// Maps to the CNPG container image tag. + /// + public required string PostgresVersion { get; set; } + + /// + /// Number of PostgreSQL instances (1 = standalone, 3 = HA with streaming replication). + /// + public int Instances { get; set; } = 3; + + /// + /// PVC storage size for each instance (e.g. "10Gi", "50Gi"). + /// + public required string StorageSize { get; set; } + + /// + /// Optional link to an S3 bucket for Barman backup/restore. + /// When set, the cluster is configured with continuous WAL archiving + /// and on-demand/scheduled base backups. + /// + public Guid? StorageLinkId { get; set; } + + /// + /// Cron schedule for automated backups (e.g. "0 0 2 * * *" for daily at 2 AM). + /// Null means no scheduled backups — only on-demand. + /// + public string? BackupSchedule { get; set; } + + /// + /// Current lifecycle status of the cluster. + /// + public CnpgClusterStatus Status { get; set; } = CnpgClusterStatus.Creating; + + /// + /// The last error encountered during a lifecycle operation. + /// Cleared on successful operations. + /// + public string? LastError { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public KubernetesCluster KubernetesCluster { get; set; } = null!; + public StorageLink? StorageLink { get; set; } + public ICollection Databases { get; set; } = []; + public ICollection Backups { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/CnpgClusterDetail.cs b/src/EntKube.Web/Data/CnpgClusterDetail.cs new file mode 100644 index 0000000..98106f6 --- /dev/null +++ b/src/EntKube.Web/Data/CnpgClusterDetail.cs @@ -0,0 +1,91 @@ +namespace EntKube.Web.Data; + +/// +/// A snapshot of a CNPG cluster's live state including pod information, +/// overall health, and timeline details queried from Kubernetes. +/// Used by the UI to show real-time status beyond what's stored in the database. +/// +public class CnpgClusterDetail +{ + /// + /// The managed cluster record from the database. + /// + public required CnpgCluster Cluster { get; set; } + + /// + /// Live pod information from Kubernetes. + /// + public List Pods { get; set; } = []; + + /// + /// The CNPG cluster phase as reported by the Cluster status (e.g. "Cluster in healthy state"). + /// + public string Phase { get; set; } = "Unknown"; + + /// + /// Number of instances that are currently ready. + /// + public int ReadyInstances { get; set; } + + /// + /// Current primary pod name. + /// + public string? CurrentPrimary { get; set; } + + /// + /// Current WAL timeline (indicates how many failovers/restores have occurred). + /// + public int? CurrentTimeline { get; set; } + + /// + /// The current write LAG in bytes across replicas (0 = fully caught up). + /// + public long ReplicationLagBytes { get; set; } +} + +/// +/// Information about a single pod in a CNPG cluster, including its role +/// (primary or replica), status, and resource usage. +/// +public class CnpgPodInfo +{ + /// + /// The pod name (e.g. "my-cluster-1", "my-cluster-2"). + /// + public required string Name { get; set; } + + /// + /// The role of this pod: "primary" or "replica". + /// + public required string Role { get; set; } + + /// + /// The Kubernetes pod phase (Running, Pending, Succeeded, Failed, Unknown). + /// + public required string Status { get; set; } + + /// + /// Whether all containers in the pod are ready. + /// + public bool Ready { get; set; } + + /// + /// The node this pod is scheduled on. + /// + public string? Node { get; set; } + + /// + /// When the pod started. + /// + public DateTime? StartTime { get; set; } + + /// + /// For replicas: replication lag in bytes behind the primary. + /// + public long? ReplicationLagBytes { get; set; } + + /// + /// Pod restart count (sum of all container restarts). + /// + public int Restarts { get; set; } +} diff --git a/src/EntKube.Web/Data/CnpgDatabase.cs b/src/EntKube.Web/Data/CnpgDatabase.cs new file mode 100644 index 0000000..8c9c462 --- /dev/null +++ b/src/EntKube.Web/Data/CnpgDatabase.cs @@ -0,0 +1,54 @@ +namespace EntKube.Web.Data; + +/// +/// Status of an individual database within a CNPG cluster. +/// +public enum CnpgDatabaseStatus +{ + Creating, + Ready, + Deleting, + Failed +} + +/// +/// A logical PostgreSQL database within a managed CNPG cluster. +/// Each database has its own owner role and a set of vault secrets +/// (host, port, database name, username, password) that can be synced +/// to Kubernetes for application consumption. +/// +/// When a database is created, EntKube runs SQL against the primary +/// to CREATE DATABASE and CREATE ROLE, then stores the credentials +/// in the tenant's vault tagged for K8s sync. +/// +public class CnpgDatabase +{ + public Guid Id { get; set; } + + /// + /// The CNPG cluster this database belongs to. + /// + public Guid CnpgClusterId { get; set; } + + /// + /// The PostgreSQL database name (e.g. "myapp", "analytics"). + /// Must be valid PostgreSQL identifier — lowercase, no spaces. + /// + public required string Name { get; set; } + + /// + /// The PostgreSQL role that owns this database (e.g. "myapp_owner"). + /// Created automatically when the database is provisioned. + /// + public required string Owner { get; set; } + + /// + /// Current status of the database. + /// + public CnpgDatabaseStatus Status { get; set; } = CnpgDatabaseStatus.Creating; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public CnpgCluster CnpgCluster { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/Customer.cs b/src/EntKube.Web/Data/Customer.cs new file mode 100644 index 0000000..b1eb321 --- /dev/null +++ b/src/EntKube.Web/Data/Customer.cs @@ -0,0 +1,23 @@ +namespace EntKube.Web.Data; + +/// +/// A customer represents an end-client or account within a tenant. Tenants +/// may serve multiple customers, and this entity tracks that relationship. +/// +public class Customer +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + /// + /// The customer's display name. Must be unique within a tenant. + /// + public required string Name { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public ICollection Apps { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/CustomerAccess.cs b/src/EntKube.Web/Data/CustomerAccess.cs new file mode 100644 index 0000000..629f3a0 --- /dev/null +++ b/src/EntKube.Web/Data/CustomerAccess.cs @@ -0,0 +1,56 @@ +namespace EntKube.Web.Data; + +/// +/// Links an application user to a specific customer, granting them access +/// to view that customer's apps, deployments, and cluster operations. +/// +/// A tenant admin assigns customer access to users. Once linked, the user +/// can visit the customer portal and see only their customer's apps — they +/// won't see other customers, tenants, or admin-level features. +/// +/// A user can have access to multiple customers (across tenants), and a +/// customer can have multiple users with access. +/// +public class CustomerAccess +{ + public string UserId { get; set; } = null!; + + public Guid CustomerId { get; set; } + + /// + /// The role this user holds for the customer. Controls what operations + /// they can perform — a Viewer can browse and see logs, an Operator + /// can also restart pods and redeploy, an Admin can manage deployments. + /// + public CustomerAccessRole Role { get; set; } = CustomerAccessRole.Viewer; + + public DateTime GrantedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public ApplicationUser User { get; set; } = null!; + public Customer Customer { get; set; } = null!; +} + +/// +/// What a user can do when accessing a customer's resources through the portal. +/// +public enum CustomerAccessRole +{ + /// + /// Can view apps, deployments, resource tree, and pod logs. + /// Cannot modify anything. + /// + Viewer, + + /// + /// Everything a Viewer can do, plus restart pods, redeploy, + /// and trigger sync operations. + /// + Operator, + + /// + /// Everything an Operator can do, plus create/delete deployments + /// and manage manifests. + /// + Admin +} diff --git a/src/EntKube.Web/Data/DeploymentEnums.cs b/src/EntKube.Web/Data/DeploymentEnums.cs new file mode 100644 index 0000000..b0a6239 --- /dev/null +++ b/src/EntKube.Web/Data/DeploymentEnums.cs @@ -0,0 +1,53 @@ +namespace EntKube.Web.Data; + +/// +/// How a deployment is defined — whether through manual form entry, +/// raw YAML manifests, or a Helm chart with dynamic values. +/// +public enum DeploymentType +{ + /// + /// Built via form: containers, services, PVCs, env vars, volume mounts. + /// The platform generates K8s manifests from the structured definition. + /// + Manual, + + /// + /// Raw YAML manifests pasted or uploaded by the user. + /// Supports Deployment, Service, PVC, ConfigMap, etc. + /// + Yaml, + + /// + /// A Helm chart from any repository. The user provides the repo URL, + /// chart name, version, and dynamic values. + /// + HelmChart +} + +/// +/// The synchronization status of a deployment, modeled after ArgoCD. +/// Tells us whether the desired state matches the live cluster state. +/// +public enum SyncStatus +{ + Unknown, + Synced, + OutOfSync, + Syncing, + Failed +} + +/// +/// The health status of a deployment's resources, modeled after ArgoCD. +/// Tells us whether the workload is actually running correctly. +/// +public enum HealthStatus +{ + Unknown, + Healthy, + Progressing, + Degraded, + Missing, + Suspended +} diff --git a/src/EntKube.Web/Data/DeploymentManifest.cs b/src/EntKube.Web/Data/DeploymentManifest.cs new file mode 100644 index 0000000..1ba56c5 --- /dev/null +++ b/src/EntKube.Web/Data/DeploymentManifest.cs @@ -0,0 +1,43 @@ +namespace EntKube.Web.Data; + +/// +/// An individual Kubernetes manifest within a deployment. For Manual deployments, +/// these are generated from the structured form (containers, services, PVCs). +/// For Yaml deployments, these are the raw YAML documents the user pasted/uploaded. +/// +/// Each manifest represents a single K8s resource (Deployment, Service, PVC, +/// ConfigMap, Secret, etc.). They are applied in SortOrder sequence. +/// +public class DeploymentManifest +{ + public Guid Id { get; set; } + + public Guid DeploymentId { get; set; } + + /// + /// The Kubernetes resource kind (e.g. "Deployment", "Service", "PersistentVolumeClaim"). + /// + public required string Kind { get; set; } + + /// + /// The resource name as it appears in metadata.name. + /// + public required string Name { get; set; } + + /// + /// Controls the order manifests are applied. Lower numbers go first. + /// Namespaces and PVCs before Deployments, Deployments before Services, etc. + /// + public int SortOrder { get; set; } + + /// + /// The full YAML content of this manifest. This is what gets applied to the cluster. + /// + public required string YamlContent { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public AppDeployment Deployment { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/DeploymentResource.cs b/src/EntKube.Web/Data/DeploymentResource.cs new file mode 100644 index 0000000..d6da190 --- /dev/null +++ b/src/EntKube.Web/Data/DeploymentResource.cs @@ -0,0 +1,60 @@ +namespace EntKube.Web.Data; + +/// +/// A tracked Kubernetes resource in the live cluster, modeled after ArgoCD's +/// resource tree. Each resource has its own sync and health status, and resources +/// form a parent-child tree (e.g. Deployment → ReplicaSet → Pod). +/// +/// The platform populates and refreshes these by watching the cluster. Users +/// see the full resource tree with per-resource health indicators, just like +/// the ArgoCD application detail view. +/// +public class DeploymentResource +{ + public Guid Id { get; set; } + + public Guid DeploymentId { get; set; } + + /// + /// Kubernetes API group (e.g. "apps", "" for core, "networking.k8s.io"). + /// + public required string Group { get; set; } + + /// + /// Kubernetes API version (e.g. "v1", "v1beta1"). + /// + public required string Version { get; set; } + + /// + /// The resource kind (e.g. "Deployment", "ReplicaSet", "Pod", "Service"). + /// + public required string Kind { get; set; } + + /// + /// The resource name from metadata.name. + /// + public required string Name { get; set; } + + /// + /// The namespace the resource lives in. Null for cluster-scoped resources. + /// + public string? Namespace { get; set; } + + public SyncStatus SyncStatus { get; set; } = SyncStatus.Unknown; + public HealthStatus HealthStatus { get; set; } = HealthStatus.Unknown; + public string? StatusMessage { get; set; } + + /// + /// Parent resource for tree rendering. A Pod's parent is a ReplicaSet, + /// a ReplicaSet's parent is a Deployment, etc. Null for root resources. + /// + public Guid? ParentResourceId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public AppDeployment Deployment { get; set; } = null!; + public DeploymentResource? ParentResource { get; set; } + public ICollection ChildResources { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/DesignTimeDbContextFactories.cs b/src/EntKube.Web/Data/DesignTimeDbContextFactories.cs new file mode 100644 index 0000000..22a207a --- /dev/null +++ b/src/EntKube.Web/Data/DesignTimeDbContextFactories.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace EntKube.Web.Data; + +/// +/// Design-time factory for the SQLite context. EF Core tooling uses this when +/// running `dotnet ef migrations add` targeting the default (SQLite) context. +/// It creates a throwaway context with a dummy connection string so the tooling +/// can inspect the model and generate migration code. +/// +public class SqliteDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + DbContextOptionsBuilder optionsBuilder = new(); + optionsBuilder.UseSqlite("DataSource=:memory:"); + return new ApplicationDbContext(optionsBuilder.Options); + } +} + +/// +/// Design-time factory for the PostgreSQL context. Used when generating +/// PostgreSQL-specific migrations via: +/// dotnet ef migrations add --context PostgresApplicationDbContext --output-dir Data/Migrations/Postgres +/// +public class PostgresDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public PostgresApplicationDbContext CreateDbContext(string[] args) + { + DbContextOptionsBuilder optionsBuilder = new(); + optionsBuilder.UseNpgsql("Host=localhost;Database=entkube_design;Username=postgres;Password=postgres"); + return new PostgresApplicationDbContext(optionsBuilder.Options); + } +} + +/// +/// Design-time factory for the SQL Server context. Used when generating +/// SQL Server-specific migrations via: +/// dotnet ef migrations add --context SqlServerApplicationDbContext --output-dir Data/Migrations/SqlServer +/// +public class SqlServerDesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public SqlServerApplicationDbContext CreateDbContext(string[] args) + { + DbContextOptionsBuilder optionsBuilder = new(); + optionsBuilder.UseSqlServer("Server=localhost;Database=entkube_design;Trusted_Connection=True;TrustServerCertificate=True"); + return new SqlServerApplicationDbContext(optionsBuilder.Options); + } +} diff --git a/src/EntKube.Web/Data/Environment.cs b/src/EntKube.Web/Data/Environment.cs new file mode 100644 index 0000000..d5a1c75 --- /dev/null +++ b/src/EntKube.Web/Data/Environment.cs @@ -0,0 +1,26 @@ +namespace EntKube.Web.Data; + +/// +/// An environment represents a deployment stage within a tenant (e.g. "Development", +/// "Staging", "Production"). Clusters, services, and other resources are scoped +/// to an environment within a tenant. +/// +public class Environment +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + /// + /// Human-friendly name for the environment (e.g. "Production"). + /// Must be unique within a tenant. + /// + public required string Name { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public ICollection AppEnvironments { get; set; } = []; + public ICollection KubernetesClusters { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/ExternalRoute.cs b/src/EntKube.Web/Data/ExternalRoute.cs new file mode 100644 index 0000000..7ea2b3a --- /dev/null +++ b/src/EntKube.Web/Data/ExternalRoute.cs @@ -0,0 +1,88 @@ +namespace EntKube.Web.Data; + +/// +/// How TLS is handled for an external route. +/// +public enum TlsMode +{ + /// Automatic TLS via a cert-manager ClusterIssuer (e.g. Let's Encrypt). + ClusterIssuer, + + /// Manual TLS — operator uploads a certificate (and optionally a private key). + Manual +} + +/// +/// An external route exposes a cluster component to the outside world via +/// a Gateway API HTTPRoute. Each route maps a hostname to a backend service +/// with TLS termination handled either automatically (ClusterIssuer) or +/// manually (uploaded certificate). +/// +/// This is the simple abstraction over Gateway API — operators just specify +/// the hostname and TLS strategy, the platform generates the resources. +/// +public class ExternalRoute +{ + public Guid Id { get; set; } + + /// The component this route exposes. + public Guid ComponentId { get; set; } + + /// + /// The hostname for external access (e.g. "grafana.example.com"). + /// Must be unique within a cluster — two routes can't serve the same hostname. + /// + public required string Hostname { get; set; } + + /// + /// The Kubernetes service name to route traffic to. + /// Defaults to the component's release name if not specified. + /// + public string? ServiceName { get; set; } + + /// + /// The port on the service to route traffic to (e.g. 80, 3000, 9090). + /// + public int ServicePort { get; set; } = 80; + + /// + /// Optional path prefix for the route (e.g. "/grafana"). Defaults to "/". + /// + public string PathPrefix { get; set; } = "/"; + + /// How TLS is handled — automatic via ClusterIssuer or manual cert upload. + public TlsMode TlsMode { get; set; } = TlsMode.ClusterIssuer; + + /// + /// Name of the ClusterIssuer to use when TlsMode is ClusterIssuer. + /// Typically "letsencrypt-prod" or "letsencrypt-staging". + /// + public string? ClusterIssuerName { get; set; } + + /// + /// PEM-encoded TLS certificate for manual TLS mode. + /// Stored encrypted at rest via the vault. + /// + public string? TlsCertificate { get; set; } + + /// + /// PEM-encoded private key for manual TLS mode. + /// Stored encrypted at rest via the vault. Optional — some scenarios use + /// certificates without keys (e.g. intermediates managed elsewhere). + /// + public string? TlsPrivateKey { get; set; } + + /// + /// The name of the Kubernetes Gateway resource to attach this route to. + /// Determined by the installed ingress controller (e.g. "traefik-gateway"). + /// + public string? GatewayName { get; set; } + + /// The namespace where the Gateway resource lives. + public string? GatewayNamespace { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public ClusterComponent Component { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/Group.cs b/src/EntKube.Web/Data/Group.cs new file mode 100644 index 0000000..a79511f --- /dev/null +++ b/src/EntKube.Web/Data/Group.cs @@ -0,0 +1,21 @@ +namespace EntKube.Web.Data; + +/// +/// A group organizes users within a tenant. Groups enable bulk permission +/// assignment and logical team structure (e.g. "Engineering", "On-Call"). +/// A group belongs to exactly one tenant. +/// +public class Group +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + public required string Name { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public ICollection Memberships { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/GroupMembership.cs b/src/EntKube.Web/Data/GroupMembership.cs new file mode 100644 index 0000000..a510bf9 --- /dev/null +++ b/src/EntKube.Web/Data/GroupMembership.cs @@ -0,0 +1,18 @@ +namespace EntKube.Web.Data; + +/// +/// Join entity between a user and a group. A user can belong to multiple +/// groups within a tenant, and a group can contain multiple users. +/// +public class GroupMembership +{ + public string UserId { get; set; } = null!; + + public Guid GroupId { get; set; } + + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public ApplicationUser User { get; set; } = null!; + public Group Group { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/KubernetesCluster.cs b/src/EntKube.Web/Data/KubernetesCluster.cs new file mode 100644 index 0000000..dd3b16d --- /dev/null +++ b/src/EntKube.Web/Data/KubernetesCluster.cs @@ -0,0 +1,45 @@ +namespace EntKube.Web.Data; + +/// +/// A Kubernetes cluster registered in EntKube. Belongs to a tenant and is +/// placed into an environment. One environment can host many clusters +/// (e.g. for regional distribution or capacity scaling). +/// +public class KubernetesCluster +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + public Guid EnvironmentId { get; set; } + + /// + /// Human-friendly cluster name (e.g. "prod-eu-west-1"). + /// Must be unique within a tenant. + /// + public required string Name { get; set; } + + /// + /// The Kubernetes API server URL used to connect to this cluster. + /// + public required string ApiServerUrl { get; set; } + + /// + /// The kubeconfig context name that was selected when registering this cluster. + /// + public string? ContextName { get; set; } + + /// + /// The raw kubeconfig YAML content for connecting to this cluster. + /// Stored so the platform can authenticate against the K8s API later. + /// In production this should be encrypted via the tenant vault. + /// + public string? Kubeconfig { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public Environment Environment { get; set; } = null!; + public ICollection Components { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516154204_CreateIdentitySchema.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516154204_CreateIdentitySchema.Designer.cs new file mode 100644 index 0000000..4f9bec6 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516154204_CreateIdentitySchema.Designer.cs @@ -0,0 +1,277 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516154204_CreateIdentitySchema")] + partial class CreateIdentitySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516154204_CreateIdentitySchema.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516154204_CreateIdentitySchema.cs new file mode 100644 index 0000000..18e14bc --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516154204_CreateIdentitySchema.cs @@ -0,0 +1,223 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class CreateIdentitySchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516171924_AddTenantsAndGroups.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516171924_AddTenantsAndGroups.Designer.cs new file mode 100644 index 0000000..3886549 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516171924_AddTenantsAndGroups.Designer.cs @@ -0,0 +1,479 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516171924_AddTenantsAndGroups")] + partial class AddTenantsAndGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516171924_AddTenantsAndGroups.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516171924_AddTenantsAndGroups.cs new file mode 100644 index 0000000..e0882b5 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516171924_AddTenantsAndGroups.cs @@ -0,0 +1,177 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddTenantsAndGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Slug = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + table.ForeignKey( + name: "FK_Groups_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TenantRoles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantRoles", x => x.Id); + table.ForeignKey( + name: "FK_TenantRoles_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GroupMemberships", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + GroupId = table.Column(type: "uuid", nullable: false), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupMemberships", x => new { x.UserId, x.GroupId }); + table.ForeignKey( + name: "FK_GroupMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupMemberships_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TenantMemberships", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantMemberships", x => new { x.UserId, x.TenantId }); + table.ForeignKey( + name: "FK_TenantMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TenantMemberships_TenantRoles_RoleId", + column: x => x.RoleId, + principalTable: "TenantRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TenantMemberships_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_GroupId", + table: "GroupMemberships", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_TenantId_Name", + table: "Groups", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_RoleId", + table: "TenantMemberships", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_TenantId", + table: "TenantMemberships", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantRoles_TenantId_Name", + table: "TenantRoles", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_Slug", + table: "Tenants", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupMemberships"); + + migrationBuilder.DropTable( + name: "TenantMemberships"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropTable( + name: "TenantRoles"); + + migrationBuilder.DropTable( + name: "Tenants"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516172614_AddEnvironmentsAndCustomers.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516172614_AddEnvironmentsAndCustomers.Designer.cs new file mode 100644 index 0000000..62ad76b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516172614_AddEnvironmentsAndCustomers.Designer.cs @@ -0,0 +1,555 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516172614_AddEnvironmentsAndCustomers")] + partial class AddEnvironmentsAndCustomers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516172614_AddEnvironmentsAndCustomers.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516172614_AddEnvironmentsAndCustomers.cs new file mode 100644 index 0000000..670046d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516172614_AddEnvironmentsAndCustomers.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddEnvironmentsAndCustomers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + table.ForeignKey( + name: "FK_Customers_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Environments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Environments", x => x.Id); + table.ForeignKey( + name: "FK_Environments_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Customers_TenantId_Name", + table: "Customers", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Environments_TenantId_Name", + table: "Environments", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + + migrationBuilder.DropTable( + name: "Environments"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516173036_AddAppsAndAppEnvironments.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516173036_AddAppsAndAppEnvironments.Designer.cs new file mode 100644 index 0000000..6497f58 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516173036_AddAppsAndAppEnvironments.Designer.cs @@ -0,0 +1,643 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516173036_AddAppsAndAppEnvironments")] + partial class AddAppsAndAppEnvironments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516173036_AddAppsAndAppEnvironments.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516173036_AddAppsAndAppEnvironments.cs new file mode 100644 index 0000000..2b4dcbc --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516173036_AddAppsAndAppEnvironments.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddAppsAndAppEnvironments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Apps", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Apps", x => x.Id); + table.ForeignKey( + name: "FK_Apps_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppEnvironments", + columns: table => new + { + AppId = table.Column(type: "uuid", nullable: false), + EnvironmentId = table.Column(type: "uuid", nullable: false), + LinkedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppEnvironments", x => new { x.AppId, x.EnvironmentId }); + table.ForeignKey( + name: "FK_AppEnvironments_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppEnvironments_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppEnvironments_EnvironmentId", + table: "AppEnvironments", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_Apps_CustomerId_Name", + table: "Apps", + columns: new[] { "CustomerId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppEnvironments"); + + migrationBuilder.DropTable( + name: "Apps"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516173329_AddKubernetesClusters.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516173329_AddKubernetesClusters.Designer.cs new file mode 100644 index 0000000..74adfb8 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516173329_AddKubernetesClusters.Designer.cs @@ -0,0 +1,701 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516173329_AddKubernetesClusters")] + partial class AddKubernetesClusters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516173329_AddKubernetesClusters.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516173329_AddKubernetesClusters.cs new file mode 100644 index 0000000..39d56be --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516173329_AddKubernetesClusters.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddKubernetesClusters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "KubernetesClusters", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + EnvironmentId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ApiServerUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KubernetesClusters", x => x.Id); + table.ForeignKey( + name: "FK_KubernetesClusters_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_KubernetesClusters_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_KubernetesClusters_EnvironmentId", + table: "KubernetesClusters", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_KubernetesClusters_TenantId_Name", + table: "KubernetesClusters", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KubernetesClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516180045_AddVaultAndComponents.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516180045_AddVaultAndComponents.Designer.cs new file mode 100644 index 0000000..cc61eeb --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516180045_AddVaultAndComponents.Designer.cs @@ -0,0 +1,885 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516180045_AddVaultAndComponents")] + partial class AddVaultAndComponents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516180045_AddVaultAndComponents.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516180045_AddVaultAndComponents.cs new file mode 100644 index 0000000..b905d1b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516180045_AddVaultAndComponents.cs @@ -0,0 +1,144 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddVaultAndComponents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClusterComponents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ComponentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClusterComponents", x => x.Id); + table.ForeignKey( + name: "FK_ClusterComponents_KubernetesClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SecretVaults", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + EncryptedDataKey = table.Column(type: "bytea", nullable: false), + Nonce = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecretVaults", x => x.Id); + table.ForeignKey( + name: "FK_SecretVaults_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "VaultSecrets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + VaultId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + EncryptedValue = table.Column(type: "bytea", nullable: false), + Nonce = table.Column(type: "bytea", nullable: false), + AppId = table.Column(type: "uuid", nullable: true), + ComponentId = table.Column(type: "uuid", nullable: true), + SyncToKubernetes = table.Column(type: "boolean", nullable: false), + KubernetesSecretName = table.Column(type: "character varying(253)", maxLength: 253, nullable: true), + KubernetesNamespace = table.Column(type: "character varying(63)", maxLength: 63, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VaultSecrets", x => x.Id); + table.ForeignKey( + name: "FK_VaultSecrets_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VaultSecrets_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VaultSecrets_SecretVaults_VaultId", + column: x => x.VaultId, + principalTable: "SecretVaults", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClusterComponents_ClusterId_Name", + table: "ClusterComponents", + columns: new[] { "ClusterId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SecretVaults_TenantId", + table: "SecretVaults", + column: "TenantId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_AppId", + table: "VaultSecrets", + column: "AppId"); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_ComponentId", + table: "VaultSecrets", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_VaultId_AppId_Name", + table: "VaultSecrets", + columns: new[] { "VaultId", "AppId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_VaultId_ComponentId_Name", + table: "VaultSecrets", + columns: new[] { "VaultId", "ComponentId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "ClusterComponents"); + + migrationBuilder.DropTable( + name: "SecretVaults"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516183618_AddKubeconfigToCluster.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516183618_AddKubeconfigToCluster.Designer.cs new file mode 100644 index 0000000..13615c7 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516183618_AddKubeconfigToCluster.Designer.cs @@ -0,0 +1,891 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516183618_AddKubeconfigToCluster")] + partial class AddKubeconfigToCluster + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516183618_AddKubeconfigToCluster.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516183618_AddKubeconfigToCluster.cs new file mode 100644 index 0000000..529e1b2 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516183618_AddKubeconfigToCluster.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddKubeconfigToCluster : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ContextName", + table: "KubernetesClusters", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Kubeconfig", + table: "KubernetesClusters", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContextName", + table: "KubernetesClusters"); + + migrationBuilder.DropColumn( + name: "Kubeconfig", + table: "KubernetesClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516194328_AddAppDeployments.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516194328_AddAppDeployments.Designer.cs new file mode 100644 index 0000000..2300bc1 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516194328_AddAppDeployments.Designer.cs @@ -0,0 +1,1140 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516194328_AddAppDeployments")] + partial class AddAppDeployments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516194328_AddAppDeployments.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516194328_AddAppDeployments.cs new file mode 100644 index 0000000..dcddcd6 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516194328_AddAppDeployments.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddAppDeployments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppDeployments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AppId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Type = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + EnvironmentId = table.Column(type: "uuid", nullable: false), + ClusterId = table.Column(type: "uuid", nullable: false), + Namespace = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + SyncStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + HealthStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + StatusMessage = table.Column(type: "text", nullable: true), + LastSyncedAt = table.Column(type: "timestamp with time zone", nullable: true), + HelmRepoUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + HelmChartName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + HelmChartVersion = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + HelmValues = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppDeployments", x => x.Id); + table.ForeignKey( + name: "FK_AppDeployments_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppDeployments_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AppDeployments_KubernetesClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "DeploymentManifests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DeploymentId = table.Column(type: "uuid", nullable: false), + Kind = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + YamlContent = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeploymentManifests", x => x.Id); + table.ForeignKey( + name: "FK_DeploymentManifests_AppDeployments_DeploymentId", + column: x => x.DeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "DeploymentResources", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DeploymentId = table.Column(type: "uuid", nullable: false), + Group = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Version = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Kind = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + Namespace = table.Column(type: "character varying(63)", maxLength: 63, nullable: true), + SyncStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + HealthStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + StatusMessage = table.Column(type: "text", nullable: true), + ParentResourceId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastUpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeploymentResources", x => x.Id); + table.ForeignKey( + name: "FK_DeploymentResources_AppDeployments_DeploymentId", + column: x => x.DeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_DeploymentResources_DeploymentResources_ParentResourceId", + column: x => x.ParentResourceId, + principalTable: "DeploymentResources", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_AppId_Name", + table: "AppDeployments", + columns: new[] { "AppId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_ClusterId", + table: "AppDeployments", + column: "ClusterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_EnvironmentId", + table: "AppDeployments", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentManifests_DeploymentId", + table: "DeploymentManifests", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentResources_DeploymentId", + table: "DeploymentResources", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentResources_ParentResourceId", + table: "DeploymentResources", + column: "ParentResourceId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DeploymentManifests"); + + migrationBuilder.DropTable( + name: "DeploymentResources"); + + migrationBuilder.DropTable( + name: "AppDeployments"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516200036_AddCustomerAccessAndDeployments.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516200036_AddCustomerAccessAndDeployments.Designer.cs new file mode 100644 index 0000000..d6f72bf --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516200036_AddCustomerAccessAndDeployments.Designer.cs @@ -0,0 +1,1182 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516200036_AddCustomerAccessAndDeployments")] + partial class AddCustomerAccessAndDeployments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516200036_AddCustomerAccessAndDeployments.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516200036_AddCustomerAccessAndDeployments.cs new file mode 100644 index 0000000..cc668ca --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516200036_AddCustomerAccessAndDeployments.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddCustomerAccessAndDeployments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CustomerAccesses", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + GrantedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomerAccesses", x => new { x.UserId, x.CustomerId }); + table.ForeignKey( + name: "FK_CustomerAccesses_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CustomerAccesses_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CustomerAccesses_CustomerId", + table: "CustomerAccesses", + column: "CustomerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CustomerAccesses"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516201953_AddClusterComponentConfiguration.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516201953_AddClusterComponentConfiguration.Designer.cs new file mode 100644 index 0000000..9a6ad7d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516201953_AddClusterComponentConfiguration.Designer.cs @@ -0,0 +1,1185 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516201953_AddClusterComponentConfiguration")] + partial class AddClusterComponentConfiguration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516201953_AddClusterComponentConfiguration.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516201953_AddClusterComponentConfiguration.cs new file mode 100644 index 0000000..4036094 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516201953_AddClusterComponentConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddClusterComponentConfiguration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Configuration", + table: "ClusterComponents", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Configuration", + table: "ClusterComponents"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516203719_AddComponentLifecycleFields.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516203719_AddComponentLifecycleFields.Designer.cs new file mode 100644 index 0000000..10e4e5a --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516203719_AddComponentLifecycleFields.Designer.cs @@ -0,0 +1,1212 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516203719_AddComponentLifecycleFields")] + partial class AddComponentLifecycleFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516203719_AddComponentLifecycleFields.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516203719_AddComponentLifecycleFields.cs new file mode 100644 index 0000000..b8de546 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516203719_AddComponentLifecycleFields.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddComponentLifecycleFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HelmChartName", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmChartVersion", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmRepoUrl", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmValues", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledAt", + table: "ClusterComponents", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastError", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Namespace", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReleaseName", + table: "ClusterComponents", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Status", + table: "ClusterComponents", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HelmChartName", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmChartVersion", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmRepoUrl", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmValues", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "InstalledAt", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "LastError", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "Namespace", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "ReleaseName", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "Status", + table: "ClusterComponents"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516210433_AddExternalRoutes.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516210433_AddExternalRoutes.Designer.cs new file mode 100644 index 0000000..94f04a1 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516210433_AddExternalRoutes.Designer.cs @@ -0,0 +1,1284 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260516210433_AddExternalRoutes")] + partial class AddExternalRoutes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260516210433_AddExternalRoutes.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260516210433_AddExternalRoutes.cs new file mode 100644 index 0000000..843e03b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260516210433_AddExternalRoutes.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddExternalRoutes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExternalRoutes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ComponentId = table.Column(type: "uuid", nullable: false), + Hostname = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + ServiceName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + ServicePort = table.Column(type: "integer", nullable: false), + PathPrefix = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + TlsMode = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ClusterIssuerName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + TlsCertificate = table.Column(type: "text", nullable: true), + TlsPrivateKey = table.Column(type: "text", nullable: true), + GatewayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + GatewayNamespace = table.Column(type: "character varying(63)", maxLength: 63, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalRoutes", x => x.Id); + table.ForeignKey( + name: "FK_ExternalRoutes_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalRoutes_ComponentId", + table: "ExternalRoutes", + column: "ComponentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExternalRoutes"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260517142315_AddStorageAndOpenStack.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260517142315_AddStorageAndOpenStack.Designer.cs new file mode 100644 index 0000000..43152ea --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260517142315_AddStorageAndOpenStack.Designer.cs @@ -0,0 +1,1443 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260517142315_AddStorageAndOpenStack")] + partial class AddStorageAndOpenStack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260517142315_AddStorageAndOpenStack.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260517142315_AddStorageAndOpenStack.cs new file mode 100644 index 0000000..2c55495 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260517142315_AddStorageAndOpenStack.cs @@ -0,0 +1,161 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddStorageAndOpenStack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OpenStackConnectionId", + table: "VaultSecrets", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "StorageLinkId", + table: "VaultSecrets", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "OpenStackConnections", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + AuthUrl = table.Column(type: "text", nullable: false), + Region = table.Column(type: "text", nullable: true), + ProjectName = table.Column(type: "text", nullable: true), + ProjectId = table.Column(type: "text", nullable: true), + UserDomainName = table.Column(type: "text", nullable: true), + ProjectDomainName = table.Column(type: "text", nullable: true), + Username = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenStackConnections", x => x.Id); + table.ForeignKey( + name: "FK_OpenStackConnections_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "StorageLinks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + EnvironmentId = table.Column(type: "uuid", nullable: false), + Provider = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "text", nullable: false), + Endpoint = table.Column(type: "text", nullable: true), + BucketName = table.Column(type: "text", nullable: true), + Region = table.Column(type: "text", nullable: true), + ComponentId = table.Column(type: "uuid", nullable: true), + OpenStackConnectionId = table.Column(type: "uuid", nullable: true), + Notes = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StorageLinks", x => x.Id); + table.ForeignKey( + name: "FK_StorageLinks_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_StorageLinks_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageLinks_OpenStackConnections_OpenStackConnectionId", + column: x => x.OpenStackConnectionId, + principalTable: "OpenStackConnections", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_StorageLinks_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_StorageLinkId", + table: "VaultSecrets", + column: "StorageLinkId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenStackConnections_TenantId", + table: "OpenStackConnections", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_ComponentId", + table: "StorageLinks", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_EnvironmentId", + table: "StorageLinks", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_OpenStackConnectionId", + table: "StorageLinks", + column: "OpenStackConnectionId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_TenantId", + table: "StorageLinks", + column: "TenantId"); + + migrationBuilder.AddForeignKey( + name: "FK_VaultSecrets_StorageLinks_StorageLinkId", + table: "VaultSecrets", + column: "StorageLinkId", + principalTable: "StorageLinks", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VaultSecrets_StorageLinks_StorageLinkId", + table: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "StorageLinks"); + + migrationBuilder.DropTable( + name: "OpenStackConnections"); + + migrationBuilder.DropIndex( + name: "IX_VaultSecrets_StorageLinkId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "OpenStackConnectionId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "StorageLinkId", + table: "VaultSecrets"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260517144100_AddStorageBindings.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260517144100_AddStorageBindings.Designer.cs new file mode 100644 index 0000000..3d2dd2b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260517144100_AddStorageBindings.Designer.cs @@ -0,0 +1,1517 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260517144100_AddStorageBindings")] + partial class AddStorageBindings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260517144100_AddStorageBindings.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260517144100_AddStorageBindings.cs new file mode 100644 index 0000000..8e31ffa --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260517144100_AddStorageBindings.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddStorageBindings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StorageBindings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + StorageLinkId = table.Column(type: "uuid", nullable: false), + AppDeploymentId = table.Column(type: "uuid", nullable: true), + ComponentId = table.Column(type: "uuid", nullable: true), + KubernetesSecretName = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + SyncEnabled = table.Column(type: "boolean", nullable: false), + LastSyncedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StorageBindings", x => x.Id); + table.ForeignKey( + name: "FK_StorageBindings_AppDeployments_AppDeploymentId", + column: x => x.AppDeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageBindings_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageBindings_StorageLinks_StorageLinkId", + column: x => x.StorageLinkId, + principalTable: "StorageLinks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_AppDeploymentId", + table: "StorageBindings", + column: "AppDeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_ComponentId", + table: "StorageBindings", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_StorageLinkId", + table: "StorageBindings", + column: "StorageLinkId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StorageBindings"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260517200918_AddCnpgManagement.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260517200918_AddCnpgManagement.Designer.cs new file mode 100644 index 0000000..99675d4 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260517200918_AddCnpgManagement.Designer.cs @@ -0,0 +1,1720 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + [Migration("20260517200918_AddCnpgManagement")] + partial class AddCnpgManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("TenantId"); + + b.HasIndex("KubernetesClusterId", "Name", "Namespace") + .IsUnique(); + + b.ToTable("CnpgClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Backups") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster") + .WithMany() + .HasForeignKey("KubernetesClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KubernetesCluster"); + + b.Navigation("StorageLink"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Databases") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase") + .WithMany() + .HasForeignKey("CnpgDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260517200918_AddCnpgManagement.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260517200918_AddCnpgManagement.cs new file mode 100644 index 0000000..821bf52 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/20260517200918_AddCnpgManagement.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + /// + public partial class AddCnpgManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CnpgDatabaseId", + table: "VaultSecrets", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "CnpgClusters", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "uuid", nullable: false), + KubernetesClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + Namespace = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + PostgresVersion = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + Instances = table.Column(type: "integer", nullable: false), + StorageSize = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + StorageLinkId = table.Column(type: "uuid", nullable: true), + BackupSchedule = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "integer", nullable: false), + LastError = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CnpgClusters", x => x.Id); + table.ForeignKey( + name: "FK_CnpgClusters_KubernetesClusters_KubernetesClusterId", + column: x => x.KubernetesClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CnpgClusters_StorageLinks_StorageLinkId", + column: x => x.StorageLinkId, + principalTable: "StorageLinks", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CnpgClusters_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CnpgBackups", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CnpgClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + Type = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + SizeBytes = table.Column(type: "bigint", nullable: true), + LastError = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CnpgBackups", x => x.Id); + table.ForeignKey( + name: "FK_CnpgBackups_CnpgClusters_CnpgClusterId", + column: x => x.CnpgClusterId, + principalTable: "CnpgClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CnpgDatabases", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CnpgClusterId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + Owner = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CnpgDatabases", x => x.Id); + table.ForeignKey( + name: "FK_CnpgDatabases_CnpgClusters_CnpgClusterId", + column: x => x.CnpgClusterId, + principalTable: "CnpgClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_CnpgDatabaseId", + table: "VaultSecrets", + column: "CnpgDatabaseId"); + + migrationBuilder.CreateIndex( + name: "IX_CnpgBackups_CnpgClusterId_Name", + table: "CnpgBackups", + columns: new[] { "CnpgClusterId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CnpgClusters_KubernetesClusterId_Name_Namespace", + table: "CnpgClusters", + columns: new[] { "KubernetesClusterId", "Name", "Namespace" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CnpgClusters_StorageLinkId", + table: "CnpgClusters", + column: "StorageLinkId"); + + migrationBuilder.CreateIndex( + name: "IX_CnpgClusters_TenantId", + table: "CnpgClusters", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_CnpgDatabases_CnpgClusterId_Name", + table: "CnpgDatabases", + columns: new[] { "CnpgClusterId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId", + table: "VaultSecrets", + column: "CnpgDatabaseId", + principalTable: "CnpgDatabases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId", + table: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "CnpgBackups"); + + migrationBuilder.DropTable( + name: "CnpgDatabases"); + + migrationBuilder.DropTable( + name: "CnpgClusters"); + + migrationBuilder.DropIndex( + name: "IX_VaultSecrets_CnpgDatabaseId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "CnpgDatabaseId", + table: "VaultSecrets"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..264dac0 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Postgres/PostgresApplicationDbContextModelSnapshot.cs @@ -0,0 +1,1717 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresApplicationDbContext))] + partial class PostgresApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterId") + .HasColumnType("uuid"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HelmChartName") + .HasColumnType("text"); + + b.Property("HelmChartVersion") + .HasColumnType("text"); + + b.Property("HelmRepoUrl") + .HasColumnType("text"); + + b.Property("HelmValues") + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Namespace") + .HasColumnType("text"); + + b.Property("ReleaseName") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Instances") + .HasColumnType("integer"); + + b.Property("KubernetesClusterId") + .HasColumnType("uuid"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("TenantId"); + + b.HasIndex("KubernetesClusterId", "Name", "Namespace") + .IsUnique(); + + b.ToTable("CnpgClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CnpgClusterId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeploymentId") + .HasColumnType("uuid"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uuid"); + + b.Property("StatusMessage") + .HasColumnType("text"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ServicePort") + .HasColumnType("integer"); + + b.Property("TlsCertificate") + .HasColumnType("text"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ContextName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Kubeconfig") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectDomainName") + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("ProjectName") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserDomainName") + .HasColumnType("text"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppDeploymentId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BucketName") + .HasColumnType("text"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppId") + .HasColumnType("uuid"); + + b.Property("CnpgDatabaseId") + .HasColumnType("uuid"); + + b.Property("ComponentId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uuid"); + + b.Property("StorageLinkId") + .HasColumnType("uuid"); + + b.Property("SyncToKubernetes") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Backups") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster") + .WithMany() + .HasForeignKey("KubernetesClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KubernetesCluster"); + + b.Navigation("StorageLink"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Databases") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase") + .WithMany() + .HasForeignKey("CnpgDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516154213_CreateIdentitySchema.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516154213_CreateIdentitySchema.Designer.cs new file mode 100644 index 0000000..da47805 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516154213_CreateIdentitySchema.Designer.cs @@ -0,0 +1,279 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516154213_CreateIdentitySchema")] + partial class CreateIdentitySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516154213_CreateIdentitySchema.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516154213_CreateIdentitySchema.cs new file mode 100644 index 0000000..5a4e545 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516154213_CreateIdentitySchema.cs @@ -0,0 +1,224 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class CreateIdentitySchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516171932_AddTenantsAndGroups.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516171932_AddTenantsAndGroups.Designer.cs new file mode 100644 index 0000000..9ff8102 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516171932_AddTenantsAndGroups.Designer.cs @@ -0,0 +1,481 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516171932_AddTenantsAndGroups")] + partial class AddTenantsAndGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516171932_AddTenantsAndGroups.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516171932_AddTenantsAndGroups.cs new file mode 100644 index 0000000..7622562 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516171932_AddTenantsAndGroups.cs @@ -0,0 +1,177 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddTenantsAndGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Slug = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + table.ForeignKey( + name: "FK_Groups_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TenantRoles", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantRoles", x => x.Id); + table.ForeignKey( + name: "FK_TenantRoles_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GroupMemberships", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + GroupId = table.Column(type: "uniqueidentifier", nullable: false), + JoinedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupMemberships", x => new { x.UserId, x.GroupId }); + table.ForeignKey( + name: "FK_GroupMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupMemberships_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TenantMemberships", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + RoleId = table.Column(type: "uniqueidentifier", nullable: false), + JoinedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantMemberships", x => new { x.UserId, x.TenantId }); + table.ForeignKey( + name: "FK_TenantMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TenantMemberships_TenantRoles_RoleId", + column: x => x.RoleId, + principalTable: "TenantRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TenantMemberships_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_GroupId", + table: "GroupMemberships", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_TenantId_Name", + table: "Groups", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_RoleId", + table: "TenantMemberships", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_TenantId", + table: "TenantMemberships", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantRoles_TenantId_Name", + table: "TenantRoles", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_Slug", + table: "Tenants", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupMemberships"); + + migrationBuilder.DropTable( + name: "TenantMemberships"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropTable( + name: "TenantRoles"); + + migrationBuilder.DropTable( + name: "Tenants"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516172617_AddEnvironmentsAndCustomers.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516172617_AddEnvironmentsAndCustomers.Designer.cs new file mode 100644 index 0000000..8ac6707 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516172617_AddEnvironmentsAndCustomers.Designer.cs @@ -0,0 +1,557 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516172617_AddEnvironmentsAndCustomers")] + partial class AddEnvironmentsAndCustomers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516172617_AddEnvironmentsAndCustomers.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516172617_AddEnvironmentsAndCustomers.cs new file mode 100644 index 0000000..5fb2510 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516172617_AddEnvironmentsAndCustomers.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddEnvironmentsAndCustomers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + table.ForeignKey( + name: "FK_Customers_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Environments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Environments", x => x.Id); + table.ForeignKey( + name: "FK_Environments_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Customers_TenantId_Name", + table: "Customers", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Environments_TenantId_Name", + table: "Environments", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + + migrationBuilder.DropTable( + name: "Environments"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516173039_AddAppsAndAppEnvironments.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173039_AddAppsAndAppEnvironments.Designer.cs new file mode 100644 index 0000000..75f1957 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173039_AddAppsAndAppEnvironments.Designer.cs @@ -0,0 +1,645 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516173039_AddAppsAndAppEnvironments")] + partial class AddAppsAndAppEnvironments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516173039_AddAppsAndAppEnvironments.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173039_AddAppsAndAppEnvironments.cs new file mode 100644 index 0000000..8e1ba92 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173039_AddAppsAndAppEnvironments.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddAppsAndAppEnvironments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Apps", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CustomerId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Apps", x => x.Id); + table.ForeignKey( + name: "FK_Apps_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppEnvironments", + columns: table => new + { + AppId = table.Column(type: "uniqueidentifier", nullable: false), + EnvironmentId = table.Column(type: "uniqueidentifier", nullable: false), + LinkedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppEnvironments", x => new { x.AppId, x.EnvironmentId }); + table.ForeignKey( + name: "FK_AppEnvironments_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppEnvironments_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppEnvironments_EnvironmentId", + table: "AppEnvironments", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_Apps_CustomerId_Name", + table: "Apps", + columns: new[] { "CustomerId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppEnvironments"); + + migrationBuilder.DropTable( + name: "Apps"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516173332_AddKubernetesClusters.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173332_AddKubernetesClusters.Designer.cs new file mode 100644 index 0000000..c87285e --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173332_AddKubernetesClusters.Designer.cs @@ -0,0 +1,703 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516173332_AddKubernetesClusters")] + partial class AddKubernetesClusters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516173332_AddKubernetesClusters.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173332_AddKubernetesClusters.cs new file mode 100644 index 0000000..a046bc6 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516173332_AddKubernetesClusters.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddKubernetesClusters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "KubernetesClusters", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + EnvironmentId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ApiServerUrl = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KubernetesClusters", x => x.Id); + table.ForeignKey( + name: "FK_KubernetesClusters_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_KubernetesClusters_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_KubernetesClusters_EnvironmentId", + table: "KubernetesClusters", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_KubernetesClusters_TenantId_Name", + table: "KubernetesClusters", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KubernetesClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516180056_AddVaultAndComponents.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516180056_AddVaultAndComponents.Designer.cs new file mode 100644 index 0000000..8373fc0 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516180056_AddVaultAndComponents.Designer.cs @@ -0,0 +1,887 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516180056_AddVaultAndComponents")] + partial class AddVaultAndComponents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516180056_AddVaultAndComponents.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516180056_AddVaultAndComponents.cs new file mode 100644 index 0000000..4e49975 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516180056_AddVaultAndComponents.cs @@ -0,0 +1,144 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddVaultAndComponents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClusterComponents", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ComponentType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClusterComponents", x => x.Id); + table.ForeignKey( + name: "FK_ClusterComponents_KubernetesClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SecretVaults", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + EncryptedDataKey = table.Column(type: "varbinary(max)", nullable: false), + Nonce = table.Column(type: "varbinary(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecretVaults", x => x.Id); + table.ForeignKey( + name: "FK_SecretVaults_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "VaultSecrets", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + VaultId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + EncryptedValue = table.Column(type: "varbinary(max)", nullable: false), + Nonce = table.Column(type: "varbinary(max)", nullable: false), + AppId = table.Column(type: "uniqueidentifier", nullable: true), + ComponentId = table.Column(type: "uniqueidentifier", nullable: true), + SyncToKubernetes = table.Column(type: "bit", nullable: false), + KubernetesSecretName = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: true), + KubernetesNamespace = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VaultSecrets", x => x.Id); + table.ForeignKey( + name: "FK_VaultSecrets_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VaultSecrets_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VaultSecrets_SecretVaults_VaultId", + column: x => x.VaultId, + principalTable: "SecretVaults", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClusterComponents_ClusterId_Name", + table: "ClusterComponents", + columns: new[] { "ClusterId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SecretVaults_TenantId", + table: "SecretVaults", + column: "TenantId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_AppId", + table: "VaultSecrets", + column: "AppId"); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_ComponentId", + table: "VaultSecrets", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_VaultId_AppId_Name", + table: "VaultSecrets", + columns: new[] { "VaultId", "AppId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_VaultId_ComponentId_Name", + table: "VaultSecrets", + columns: new[] { "VaultId", "ComponentId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "ClusterComponents"); + + migrationBuilder.DropTable( + name: "SecretVaults"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516183621_AddKubeconfigToCluster.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516183621_AddKubeconfigToCluster.Designer.cs new file mode 100644 index 0000000..65092d9 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516183621_AddKubeconfigToCluster.Designer.cs @@ -0,0 +1,893 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516183621_AddKubeconfigToCluster")] + partial class AddKubeconfigToCluster + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516183621_AddKubeconfigToCluster.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516183621_AddKubeconfigToCluster.cs new file mode 100644 index 0000000..e68193f --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516183621_AddKubeconfigToCluster.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddKubeconfigToCluster : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ContextName", + table: "KubernetesClusters", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Kubeconfig", + table: "KubernetesClusters", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContextName", + table: "KubernetesClusters"); + + migrationBuilder.DropColumn( + name: "Kubeconfig", + table: "KubernetesClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516194332_AddAppDeployments.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516194332_AddAppDeployments.Designer.cs new file mode 100644 index 0000000..10843b8 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516194332_AddAppDeployments.Designer.cs @@ -0,0 +1,1142 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516194332_AddAppDeployments")] + partial class AddAppDeployments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516194332_AddAppDeployments.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516194332_AddAppDeployments.cs new file mode 100644 index 0000000..5cfde69 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516194332_AddAppDeployments.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddAppDeployments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppDeployments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + AppId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Type = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + EnvironmentId = table.Column(type: "uniqueidentifier", nullable: false), + ClusterId = table.Column(type: "uniqueidentifier", nullable: false), + Namespace = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: false), + SyncStatus = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + HealthStatus = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + StatusMessage = table.Column(type: "nvarchar(max)", nullable: true), + LastSyncedAt = table.Column(type: "datetime2", nullable: true), + HelmRepoUrl = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + HelmChartName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + HelmChartVersion = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: true), + HelmValues = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppDeployments", x => x.Id); + table.ForeignKey( + name: "FK_AppDeployments_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppDeployments_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AppDeployments_KubernetesClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "DeploymentManifests", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + DeploymentId = table.Column(type: "uniqueidentifier", nullable: false), + Kind = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + YamlContent = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeploymentManifests", x => x.Id); + table.ForeignKey( + name: "FK_DeploymentManifests_AppDeployments_DeploymentId", + column: x => x.DeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "DeploymentResources", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + DeploymentId = table.Column(type: "uniqueidentifier", nullable: false), + Group = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Version = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Kind = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + Namespace = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: true), + SyncStatus = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + HealthStatus = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + StatusMessage = table.Column(type: "nvarchar(max)", nullable: true), + ParentResourceId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastUpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeploymentResources", x => x.Id); + table.ForeignKey( + name: "FK_DeploymentResources_AppDeployments_DeploymentId", + column: x => x.DeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_DeploymentResources_DeploymentResources_ParentResourceId", + column: x => x.ParentResourceId, + principalTable: "DeploymentResources", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_AppId_Name", + table: "AppDeployments", + columns: new[] { "AppId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_ClusterId", + table: "AppDeployments", + column: "ClusterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_EnvironmentId", + table: "AppDeployments", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentManifests_DeploymentId", + table: "DeploymentManifests", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentResources_DeploymentId", + table: "DeploymentResources", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentResources_ParentResourceId", + table: "DeploymentResources", + column: "ParentResourceId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DeploymentManifests"); + + migrationBuilder.DropTable( + name: "DeploymentResources"); + + migrationBuilder.DropTable( + name: "AppDeployments"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516200039_AddCustomerAccessAndDeployments.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516200039_AddCustomerAccessAndDeployments.Designer.cs new file mode 100644 index 0000000..16b3a7a --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516200039_AddCustomerAccessAndDeployments.Designer.cs @@ -0,0 +1,1184 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516200039_AddCustomerAccessAndDeployments")] + partial class AddCustomerAccessAndDeployments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516200039_AddCustomerAccessAndDeployments.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516200039_AddCustomerAccessAndDeployments.cs new file mode 100644 index 0000000..615cc13 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516200039_AddCustomerAccessAndDeployments.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddCustomerAccessAndDeployments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CustomerAccesses", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + CustomerId = table.Column(type: "uniqueidentifier", nullable: false), + Role = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + GrantedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomerAccesses", x => new { x.UserId, x.CustomerId }); + table.ForeignKey( + name: "FK_CustomerAccesses_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CustomerAccesses_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CustomerAccesses_CustomerId", + table: "CustomerAccesses", + column: "CustomerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CustomerAccesses"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516202002_AddClusterComponentConfiguration.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516202002_AddClusterComponentConfiguration.Designer.cs new file mode 100644 index 0000000..63d5f1c --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516202002_AddClusterComponentConfiguration.Designer.cs @@ -0,0 +1,1187 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516202002_AddClusterComponentConfiguration")] + partial class AddClusterComponentConfiguration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516202002_AddClusterComponentConfiguration.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516202002_AddClusterComponentConfiguration.cs new file mode 100644 index 0000000..e570bfd --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516202002_AddClusterComponentConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddClusterComponentConfiguration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Configuration", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Configuration", + table: "ClusterComponents"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516203722_AddComponentLifecycleFields.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516203722_AddComponentLifecycleFields.Designer.cs new file mode 100644 index 0000000..45056b2 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516203722_AddComponentLifecycleFields.Designer.cs @@ -0,0 +1,1214 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516203722_AddComponentLifecycleFields")] + partial class AddComponentLifecycleFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516203722_AddComponentLifecycleFields.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516203722_AddComponentLifecycleFields.cs new file mode 100644 index 0000000..9afc95b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516203722_AddComponentLifecycleFields.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddComponentLifecycleFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HelmChartName", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmChartVersion", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmRepoUrl", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmValues", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledAt", + table: "ClusterComponents", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastError", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Namespace", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReleaseName", + table: "ClusterComponents", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Status", + table: "ClusterComponents", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HelmChartName", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmChartVersion", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmRepoUrl", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmValues", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "InstalledAt", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "LastError", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "Namespace", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "ReleaseName", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "Status", + table: "ClusterComponents"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516210455_AddExternalRoutes.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516210455_AddExternalRoutes.Designer.cs new file mode 100644 index 0000000..82cdc0b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516210455_AddExternalRoutes.Designer.cs @@ -0,0 +1,1286 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260516210455_AddExternalRoutes")] + partial class AddExternalRoutes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260516210455_AddExternalRoutes.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260516210455_AddExternalRoutes.cs new file mode 100644 index 0000000..dccf42b --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260516210455_AddExternalRoutes.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddExternalRoutes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExternalRoutes", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ComponentId = table.Column(type: "uniqueidentifier", nullable: false), + Hostname = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + ServiceName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + ServicePort = table.Column(type: "int", nullable: false), + PathPrefix = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + TlsMode = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + ClusterIssuerName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + TlsCertificate = table.Column(type: "nvarchar(max)", nullable: true), + TlsPrivateKey = table.Column(type: "nvarchar(max)", nullable: true), + GatewayName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + GatewayNamespace = table.Column(type: "nvarchar(63)", maxLength: 63, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalRoutes", x => x.Id); + table.ForeignKey( + name: "FK_ExternalRoutes_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalRoutes_ComponentId", + table: "ExternalRoutes", + column: "ComponentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExternalRoutes"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260517142319_AddStorageAndOpenStack.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260517142319_AddStorageAndOpenStack.Designer.cs new file mode 100644 index 0000000..0b8753d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260517142319_AddStorageAndOpenStack.Designer.cs @@ -0,0 +1,1445 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260517142319_AddStorageAndOpenStack")] + partial class AddStorageAndOpenStack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260517142319_AddStorageAndOpenStack.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260517142319_AddStorageAndOpenStack.cs new file mode 100644 index 0000000..d5e2157 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260517142319_AddStorageAndOpenStack.cs @@ -0,0 +1,161 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddStorageAndOpenStack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OpenStackConnectionId", + table: "VaultSecrets", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "StorageLinkId", + table: "VaultSecrets", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "OpenStackConnections", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false), + AuthUrl = table.Column(type: "nvarchar(max)", nullable: false), + Region = table.Column(type: "nvarchar(max)", nullable: true), + ProjectName = table.Column(type: "nvarchar(max)", nullable: true), + ProjectId = table.Column(type: "nvarchar(max)", nullable: true), + UserDomainName = table.Column(type: "nvarchar(max)", nullable: true), + ProjectDomainName = table.Column(type: "nvarchar(max)", nullable: true), + Username = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenStackConnections", x => x.Id); + table.ForeignKey( + name: "FK_OpenStackConnections_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "StorageLinks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + EnvironmentId = table.Column(type: "uniqueidentifier", nullable: false), + Provider = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Endpoint = table.Column(type: "nvarchar(max)", nullable: true), + BucketName = table.Column(type: "nvarchar(max)", nullable: true), + Region = table.Column(type: "nvarchar(max)", nullable: true), + ComponentId = table.Column(type: "uniqueidentifier", nullable: true), + OpenStackConnectionId = table.Column(type: "uniqueidentifier", nullable: true), + Notes = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StorageLinks", x => x.Id); + table.ForeignKey( + name: "FK_StorageLinks_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_StorageLinks_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageLinks_OpenStackConnections_OpenStackConnectionId", + column: x => x.OpenStackConnectionId, + principalTable: "OpenStackConnections", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_StorageLinks_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_StorageLinkId", + table: "VaultSecrets", + column: "StorageLinkId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenStackConnections_TenantId", + table: "OpenStackConnections", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_ComponentId", + table: "StorageLinks", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_EnvironmentId", + table: "StorageLinks", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_OpenStackConnectionId", + table: "StorageLinks", + column: "OpenStackConnectionId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_TenantId", + table: "StorageLinks", + column: "TenantId"); + + migrationBuilder.AddForeignKey( + name: "FK_VaultSecrets_StorageLinks_StorageLinkId", + table: "VaultSecrets", + column: "StorageLinkId", + principalTable: "StorageLinks", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VaultSecrets_StorageLinks_StorageLinkId", + table: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "StorageLinks"); + + migrationBuilder.DropTable( + name: "OpenStackConnections"); + + migrationBuilder.DropIndex( + name: "IX_VaultSecrets_StorageLinkId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "OpenStackConnectionId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "StorageLinkId", + table: "VaultSecrets"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260517144111_AddStorageBindings.Designer.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260517144111_AddStorageBindings.Designer.cs new file mode 100644 index 0000000..af34ae5 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260517144111_AddStorageBindings.Designer.cs @@ -0,0 +1,1519 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + [Migration("20260517144111_AddStorageBindings")] + partial class AddStorageBindings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/20260517144111_AddStorageBindings.cs b/src/EntKube.Web/Data/Migrations/SqlServer/20260517144111_AddStorageBindings.cs new file mode 100644 index 0000000..0322967 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/20260517144111_AddStorageBindings.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + /// + public partial class AddStorageBindings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StorageBindings", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + StorageLinkId = table.Column(type: "uniqueidentifier", nullable: false), + AppDeploymentId = table.Column(type: "uniqueidentifier", nullable: true), + ComponentId = table.Column(type: "uniqueidentifier", nullable: true), + KubernetesSecretName = table.Column(type: "nvarchar(253)", maxLength: 253, nullable: false), + SyncEnabled = table.Column(type: "bit", nullable: false), + LastSyncedAt = table.Column(type: "datetime2", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StorageBindings", x => x.Id); + table.ForeignKey( + name: "FK_StorageBindings_AppDeployments_AppDeploymentId", + column: x => x.AppDeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageBindings_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageBindings_StorageLinks_StorageLinkId", + column: x => x.StorageLinkId, + principalTable: "StorageLinks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_AppDeploymentId", + table: "StorageBindings", + column: "AppDeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_ComponentId", + table: "StorageBindings", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_StorageLinkId", + table: "StorageBindings", + column: "StorageLinkId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StorageBindings"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..ab8fb0d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/SqlServer/SqlServerApplicationDbContextModelSnapshot.cs @@ -0,0 +1,1516 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.SqlServer +{ + [DbContext(typeof(SqlServerApplicationDbContext))] + partial class SqlServerApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("LinkedAt") + .HasColumnType("datetime2"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Configuration") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HelmChartName") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmChartVersion") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmRepoUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("HelmValues") + .HasColumnType("nvarchar(max)"); + + b.Property("InstalledAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Namespace") + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CustomerId") + .HasColumnType("uniqueidentifier"); + + b.Property("GrantedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("ParentResourceId") + .HasColumnType("uniqueidentifier"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ServicePort") + .HasColumnType("int"); + + b.Property("TlsCertificate") + .HasColumnType("nvarchar(max)"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TlsPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("GroupId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ContextName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Kubeconfig") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectId") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserDomainName") + .HasColumnType("nvarchar(max)"); + + b.Property("Username") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppDeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime2"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncEnabled") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BucketName") + .HasColumnType("nvarchar(max)"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Endpoint") + .HasColumnType("nvarchar(max)"); + + b.Property("EnvironmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Region") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("JoinedAt") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AppId") + .HasColumnType("uniqueidentifier"); + + b.Property("ComponentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("nvarchar(63)"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("nvarchar(253)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("OpenStackConnectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("StorageLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("SyncToKubernetes") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VaultId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516154156_CreateIdentitySchema.Designer.cs similarity index 73% rename from src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs rename to src/EntKube.Web/Data/Migrations/Sqlite/20260516154156_CreateIdentitySchema.Designer.cs index 85a1442..9c9f75e 100644 --- a/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516154156_CreateIdentitySchema.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using EntKube.Web.Data; using Microsoft.EntityFrameworkCore; @@ -8,17 +8,17 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace EntKube.Web.Migrations +namespace EntKube.Web.Data.Migrations.Sqlite { [DbContext(typeof(ApplicationDbContext))] - [Migration("00000000000000_CreateIdentitySchema")] + [Migration("20260516154156_CreateIdentitySchema")] partial class CreateIdentitySchema { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => { @@ -57,7 +57,6 @@ namespace EntKube.Web.Migrations .HasColumnType("TEXT"); b.Property("PhoneNumber") - .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -160,11 +159,9 @@ namespace EntKube.Web.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -181,23 +178,6 @@ namespace EntKube.Web.Migrations b.ToTable("AspNetUserLogins", (string)null); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -219,11 +199,9 @@ namespace EntKube.Web.Migrations .HasColumnType("TEXT"); b.Property("LoginProvider") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -261,66 +239,6 @@ namespace EntKube.Web.Migrations .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("EntKube.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId") - .HasColumnType("BLOB"); - - b1.Property("AttestationObject") - .IsRequired() - .HasColumnType("BLOB"); - - b1.Property("ClientDataJson") - .IsRequired() - .HasColumnType("BLOB"); - - b1.Property("CreatedAt") - .HasColumnType("TEXT"); - - b1.Property("IsBackedUp") - .HasColumnType("INTEGER"); - - b1.Property("IsBackupEligible") - .HasColumnType("INTEGER"); - - b1.Property("IsUserVerified") - .HasColumnType("INTEGER"); - - b1.Property("Name") - .HasColumnType("TEXT"); - - b1.Property("PublicKey") - .IsRequired() - .HasColumnType("BLOB"); - - b1.Property("SignCount") - .HasColumnType("INTEGER"); - - b1.PrimitiveCollection("Transports") - .HasColumnType("TEXT"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1.ToJson("Data"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516154156_CreateIdentitySchema.cs similarity index 86% rename from src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs rename to src/EntKube.Web/Data/Migrations/Sqlite/20260516154156_CreateIdentitySchema.cs index ae99ca8..f8c3715 100644 --- a/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516154156_CreateIdentitySchema.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace EntKube.Web.Migrations +namespace EntKube.Web.Data.Migrations.Sqlite { /// public partial class CreateIdentitySchema : Migration @@ -38,7 +38,7 @@ namespace EntKube.Web.Migrations PasswordHash = table.Column(type: "TEXT", nullable: true), SecurityStamp = table.Column(type: "TEXT", nullable: true), ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), - PhoneNumber = table.Column(type: "TEXT", maxLength: 256, nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), LockoutEnd = table.Column(type: "TEXT", nullable: true), @@ -96,8 +96,8 @@ namespace EntKube.Web.Migrations name: "AspNetUserLogins", columns: table => new { - LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), - ProviderKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), ProviderDisplayName = table.Column(type: "TEXT", nullable: true), UserId = table.Column(type: "TEXT", nullable: false) }, @@ -112,25 +112,6 @@ namespace EntKube.Web.Migrations onDelete: ReferentialAction.Cascade); }); - migrationBuilder.CreateTable( - name: "AspNetUserPasskeys", - columns: table => new - { - CredentialId = table.Column(type: "BLOB", maxLength: 1024, nullable: false), - UserId = table.Column(type: "TEXT", nullable: false), - Data = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); - table.ForeignKey( - name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - migrationBuilder.CreateTable( name: "AspNetUserRoles", columns: table => new @@ -160,8 +141,8 @@ namespace EntKube.Web.Migrations columns: table => new { UserId = table.Column(type: "TEXT", nullable: false), - LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), - Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), Value = table.Column(type: "TEXT", nullable: true) }, constraints: table => @@ -196,11 +177,6 @@ namespace EntKube.Web.Migrations table: "AspNetUserLogins", column: "UserId"); - migrationBuilder.CreateIndex( - name: "IX_AspNetUserPasskeys_UserId", - table: "AspNetUserPasskeys", - column: "UserId"); - migrationBuilder.CreateIndex( name: "IX_AspNetUserRoles_RoleId", table: "AspNetUserRoles", @@ -230,9 +206,6 @@ namespace EntKube.Web.Migrations migrationBuilder.DropTable( name: "AspNetUserLogins"); - migrationBuilder.DropTable( - name: "AspNetUserPasskeys"); - migrationBuilder.DropTable( name: "AspNetUserRoles"); diff --git a/src/EntKube.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516171915_AddTenantsAndGroups.Designer.cs similarity index 59% rename from src/EntKube.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs rename to src/EntKube.Web/Data/Migrations/Sqlite/20260516171915_AddTenantsAndGroups.Designer.cs index 0238577..4750ae8 100644 --- a/src/EntKube.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516171915_AddTenantsAndGroups.Designer.cs @@ -1,21 +1,24 @@ -// +// using System; using EntKube.Web.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace EntKube.Web.Migrations +namespace EntKube.Web.Data.Migrations.Sqlite { [DbContext(typeof(ApplicationDbContext))] - partial class ApplicationDbContextModelSnapshot : ModelSnapshot + [Migration("20260516171915_AddTenantsAndGroups")] + partial class AddTenantsAndGroups { - protected override void BuildModel(ModelBuilder modelBuilder) + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => { @@ -54,7 +57,6 @@ namespace EntKube.Web.Migrations .HasColumnType("TEXT"); b.Property("PhoneNumber") - .HasMaxLength(256) .HasColumnType("TEXT"); b.Property("PhoneNumberConfirmed") @@ -82,6 +84,121 @@ namespace EntKube.Web.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") @@ -157,11 +274,9 @@ namespace EntKube.Web.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { b.Property("LoginProvider") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderKey") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("ProviderDisplayName") @@ -178,23 +293,6 @@ namespace EntKube.Web.Migrations b.ToTable("AspNetUserLogins", (string)null); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.Property("UserId") @@ -216,11 +314,9 @@ namespace EntKube.Web.Migrations .HasColumnType("TEXT"); b.Property("LoginProvider") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Name") - .HasMaxLength(128) .HasColumnType("TEXT"); b.Property("Value") @@ -231,6 +327,74 @@ namespace EntKube.Web.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -258,66 +422,6 @@ namespace EntKube.Web.Migrations .IsRequired(); }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("EntKube.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId") - .HasColumnType("BLOB"); - - b1.Property("AttestationObject") - .IsRequired() - .HasColumnType("BLOB"); - - b1.Property("ClientDataJson") - .IsRequired() - .HasColumnType("BLOB"); - - b1.Property("CreatedAt") - .HasColumnType("TEXT"); - - b1.Property("IsBackedUp") - .HasColumnType("INTEGER"); - - b1.Property("IsBackupEligible") - .HasColumnType("INTEGER"); - - b1.Property("IsUserVerified") - .HasColumnType("INTEGER"); - - b1.Property("Name") - .HasColumnType("TEXT"); - - b1.Property("PublicKey") - .IsRequired() - .HasColumnType("BLOB"); - - b1.Property("SignCount") - .HasColumnType("INTEGER"); - - b1.PrimitiveCollection("Transports") - .HasColumnType("TEXT"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1.ToJson("Data"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -341,6 +445,25 @@ namespace EntKube.Web.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); #pragma warning restore 612, 618 } } diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516171915_AddTenantsAndGroups.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516171915_AddTenantsAndGroups.cs new file mode 100644 index 0000000..97104e6 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516171915_AddTenantsAndGroups.cs @@ -0,0 +1,177 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddTenantsAndGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Slug = table.Column(type: "TEXT", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + table.ForeignKey( + name: "FK_Groups_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TenantRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantRoles", x => x.Id); + table.ForeignKey( + name: "FK_TenantRoles_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GroupMemberships", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + GroupId = table.Column(type: "TEXT", nullable: false), + JoinedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupMemberships", x => new { x.UserId, x.GroupId }); + table.ForeignKey( + name: "FK_GroupMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupMemberships_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TenantMemberships", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false), + JoinedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TenantMemberships", x => new { x.UserId, x.TenantId }); + table.ForeignKey( + name: "FK_TenantMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TenantMemberships_TenantRoles_RoleId", + column: x => x.RoleId, + principalTable: "TenantRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TenantMemberships_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupMemberships_GroupId", + table: "GroupMemberships", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_TenantId_Name", + table: "Groups", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_RoleId", + table: "TenantMemberships", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantMemberships_TenantId", + table: "TenantMemberships", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_TenantRoles_TenantId_Name", + table: "TenantRoles", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Tenants_Slug", + table: "Tenants", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupMemberships"); + + migrationBuilder.DropTable( + name: "TenantMemberships"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropTable( + name: "TenantRoles"); + + migrationBuilder.DropTable( + name: "Tenants"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516172611_AddEnvironmentsAndCustomers.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516172611_AddEnvironmentsAndCustomers.Designer.cs new file mode 100644 index 0000000..af97d72 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516172611_AddEnvironmentsAndCustomers.Designer.cs @@ -0,0 +1,546 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516172611_AddEnvironmentsAndCustomers")] + partial class AddEnvironmentsAndCustomers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516172611_AddEnvironmentsAndCustomers.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516172611_AddEnvironmentsAndCustomers.cs new file mode 100644 index 0000000..8850b67 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516172611_AddEnvironmentsAndCustomers.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddEnvironmentsAndCustomers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + table.ForeignKey( + name: "FK_Customers_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Environments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Environments", x => x.Id); + table.ForeignKey( + name: "FK_Environments_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Customers_TenantId_Name", + table: "Customers", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Environments_TenantId_Name", + table: "Environments", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + + migrationBuilder.DropTable( + name: "Environments"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516173033_AddAppsAndAppEnvironments.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173033_AddAppsAndAppEnvironments.Designer.cs new file mode 100644 index 0000000..18d4b5c --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173033_AddAppsAndAppEnvironments.Designer.cs @@ -0,0 +1,634 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516173033_AddAppsAndAppEnvironments")] + partial class AddAppsAndAppEnvironments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516173033_AddAppsAndAppEnvironments.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173033_AddAppsAndAppEnvironments.cs new file mode 100644 index 0000000..a810abd --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173033_AddAppsAndAppEnvironments.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddAppsAndAppEnvironments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Apps", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CustomerId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Apps", x => x.Id); + table.ForeignKey( + name: "FK_Apps_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppEnvironments", + columns: table => new + { + AppId = table.Column(type: "TEXT", nullable: false), + EnvironmentId = table.Column(type: "TEXT", nullable: false), + LinkedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppEnvironments", x => new { x.AppId, x.EnvironmentId }); + table.ForeignKey( + name: "FK_AppEnvironments_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppEnvironments_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppEnvironments_EnvironmentId", + table: "AppEnvironments", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_Apps_CustomerId_Name", + table: "Apps", + columns: new[] { "CustomerId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppEnvironments"); + + migrationBuilder.DropTable( + name: "Apps"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516173326_AddKubernetesClusters.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173326_AddKubernetesClusters.Designer.cs new file mode 100644 index 0000000..593007c --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173326_AddKubernetesClusters.Designer.cs @@ -0,0 +1,692 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516173326_AddKubernetesClusters")] + partial class AddKubernetesClusters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516173326_AddKubernetesClusters.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173326_AddKubernetesClusters.cs new file mode 100644 index 0000000..e67fb38 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516173326_AddKubernetesClusters.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddKubernetesClusters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "KubernetesClusters", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + EnvironmentId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + ApiServerUrl = table.Column(type: "TEXT", maxLength: 500, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_KubernetesClusters", x => x.Id); + table.ForeignKey( + name: "FK_KubernetesClusters_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_KubernetesClusters_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_KubernetesClusters_EnvironmentId", + table: "KubernetesClusters", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_KubernetesClusters_TenantId_Name", + table: "KubernetesClusters", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KubernetesClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516180036_AddVaultAndComponents.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516180036_AddVaultAndComponents.Designer.cs new file mode 100644 index 0000000..c46f978 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516180036_AddVaultAndComponents.Designer.cs @@ -0,0 +1,876 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516180036_AddVaultAndComponents")] + partial class AddVaultAndComponents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516180036_AddVaultAndComponents.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516180036_AddVaultAndComponents.cs new file mode 100644 index 0000000..e4afa51 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516180036_AddVaultAndComponents.cs @@ -0,0 +1,144 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddVaultAndComponents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClusterComponents", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + ComponentType = table.Column(type: "TEXT", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClusterComponents", x => x.Id); + table.ForeignKey( + name: "FK_ClusterComponents_KubernetesClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SecretVaults", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + EncryptedDataKey = table.Column(type: "BLOB", nullable: false), + Nonce = table.Column(type: "BLOB", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecretVaults", x => x.Id); + table.ForeignKey( + name: "FK_SecretVaults_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "VaultSecrets", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + VaultId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + EncryptedValue = table.Column(type: "BLOB", nullable: false), + Nonce = table.Column(type: "BLOB", nullable: false), + AppId = table.Column(type: "TEXT", nullable: true), + ComponentId = table.Column(type: "TEXT", nullable: true), + SyncToKubernetes = table.Column(type: "INTEGER", nullable: false), + KubernetesSecretName = table.Column(type: "TEXT", maxLength: 253, nullable: true), + KubernetesNamespace = table.Column(type: "TEXT", maxLength: 63, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VaultSecrets", x => x.Id); + table.ForeignKey( + name: "FK_VaultSecrets_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VaultSecrets_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_VaultSecrets_SecretVaults_VaultId", + column: x => x.VaultId, + principalTable: "SecretVaults", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ClusterComponents_ClusterId_Name", + table: "ClusterComponents", + columns: new[] { "ClusterId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SecretVaults_TenantId", + table: "SecretVaults", + column: "TenantId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_AppId", + table: "VaultSecrets", + column: "AppId"); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_ComponentId", + table: "VaultSecrets", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_VaultId_AppId_Name", + table: "VaultSecrets", + columns: new[] { "VaultId", "AppId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_VaultId_ComponentId_Name", + table: "VaultSecrets", + columns: new[] { "VaultId", "ComponentId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "ClusterComponents"); + + migrationBuilder.DropTable( + name: "SecretVaults"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516183615_AddKubeconfigToCluster.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516183615_AddKubeconfigToCluster.Designer.cs new file mode 100644 index 0000000..5e8b3d2 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516183615_AddKubeconfigToCluster.Designer.cs @@ -0,0 +1,882 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516183615_AddKubeconfigToCluster")] + partial class AddKubeconfigToCluster + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516183615_AddKubeconfigToCluster.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516183615_AddKubeconfigToCluster.cs new file mode 100644 index 0000000..58952d9 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516183615_AddKubeconfigToCluster.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddKubeconfigToCluster : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ContextName", + table: "KubernetesClusters", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Kubeconfig", + table: "KubernetesClusters", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContextName", + table: "KubernetesClusters"); + + migrationBuilder.DropColumn( + name: "Kubeconfig", + table: "KubernetesClusters"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516194325_AddAppDeployments.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516194325_AddAppDeployments.Designer.cs new file mode 100644 index 0000000..d13de4d --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516194325_AddAppDeployments.Designer.cs @@ -0,0 +1,1131 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516194325_AddAppDeployments")] + partial class AddAppDeployments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516194325_AddAppDeployments.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516194325_AddAppDeployments.cs new file mode 100644 index 0000000..189debe --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516194325_AddAppDeployments.cs @@ -0,0 +1,162 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddAppDeployments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppDeployments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AppId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Type = table.Column(type: "TEXT", maxLength: 20, nullable: false), + EnvironmentId = table.Column(type: "TEXT", nullable: false), + ClusterId = table.Column(type: "TEXT", nullable: false), + Namespace = table.Column(type: "TEXT", maxLength: 63, nullable: false), + SyncStatus = table.Column(type: "TEXT", maxLength: 20, nullable: false), + HealthStatus = table.Column(type: "TEXT", maxLength: 20, nullable: false), + StatusMessage = table.Column(type: "TEXT", nullable: true), + LastSyncedAt = table.Column(type: "TEXT", nullable: true), + HelmRepoUrl = table.Column(type: "TEXT", maxLength: 500, nullable: true), + HelmChartName = table.Column(type: "TEXT", maxLength: 200, nullable: true), + HelmChartVersion = table.Column(type: "TEXT", maxLength: 50, nullable: true), + HelmValues = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppDeployments", x => x.Id); + table.ForeignKey( + name: "FK_AppDeployments_Apps_AppId", + column: x => x.AppId, + principalTable: "Apps", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppDeployments_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AppDeployments_KubernetesClusters_ClusterId", + column: x => x.ClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "DeploymentManifests", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DeploymentId = table.Column(type: "TEXT", nullable: false), + Kind = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 253, nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + YamlContent = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeploymentManifests", x => x.Id); + table.ForeignKey( + name: "FK_DeploymentManifests_AppDeployments_DeploymentId", + column: x => x.DeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "DeploymentResources", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DeploymentId = table.Column(type: "TEXT", nullable: false), + Group = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Version = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Kind = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 253, nullable: false), + Namespace = table.Column(type: "TEXT", maxLength: 63, nullable: true), + SyncStatus = table.Column(type: "TEXT", maxLength: 20, nullable: false), + HealthStatus = table.Column(type: "TEXT", maxLength: 20, nullable: false), + StatusMessage = table.Column(type: "TEXT", nullable: true), + ParentResourceId = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastUpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeploymentResources", x => x.Id); + table.ForeignKey( + name: "FK_DeploymentResources_AppDeployments_DeploymentId", + column: x => x.DeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_DeploymentResources_DeploymentResources_ParentResourceId", + column: x => x.ParentResourceId, + principalTable: "DeploymentResources", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_AppId_Name", + table: "AppDeployments", + columns: new[] { "AppId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_ClusterId", + table: "AppDeployments", + column: "ClusterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppDeployments_EnvironmentId", + table: "AppDeployments", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentManifests_DeploymentId", + table: "DeploymentManifests", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentResources_DeploymentId", + table: "DeploymentResources", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_DeploymentResources_ParentResourceId", + table: "DeploymentResources", + column: "ParentResourceId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DeploymentManifests"); + + migrationBuilder.DropTable( + name: "DeploymentResources"); + + migrationBuilder.DropTable( + name: "AppDeployments"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516200032_AddCustomerAccessAndDeployments.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516200032_AddCustomerAccessAndDeployments.Designer.cs new file mode 100644 index 0000000..24c08b6 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516200032_AddCustomerAccessAndDeployments.Designer.cs @@ -0,0 +1,1173 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516200032_AddCustomerAccessAndDeployments")] + partial class AddCustomerAccessAndDeployments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516200032_AddCustomerAccessAndDeployments.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516200032_AddCustomerAccessAndDeployments.cs new file mode 100644 index 0000000..4f3acfa --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516200032_AddCustomerAccessAndDeployments.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddCustomerAccessAndDeployments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CustomerAccesses", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + CustomerId = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "TEXT", maxLength: 20, nullable: false), + GrantedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomerAccesses", x => new { x.UserId, x.CustomerId }); + table.ForeignKey( + name: "FK_CustomerAccesses_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CustomerAccesses_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CustomerAccesses_CustomerId", + table: "CustomerAccesses", + column: "CustomerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CustomerAccesses"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516201938_AddClusterComponentConfiguration.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516201938_AddClusterComponentConfiguration.Designer.cs new file mode 100644 index 0000000..5810b4e --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516201938_AddClusterComponentConfiguration.Designer.cs @@ -0,0 +1,1176 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516201938_AddClusterComponentConfiguration")] + partial class AddClusterComponentConfiguration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516201938_AddClusterComponentConfiguration.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516201938_AddClusterComponentConfiguration.cs new file mode 100644 index 0000000..39c9bb1 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516201938_AddClusterComponentConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddClusterComponentConfiguration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Configuration", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Configuration", + table: "ClusterComponents"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516203715_AddComponentLifecycleFields.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516203715_AddComponentLifecycleFields.Designer.cs new file mode 100644 index 0000000..c39ee0e --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516203715_AddComponentLifecycleFields.Designer.cs @@ -0,0 +1,1203 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516203715_AddComponentLifecycleFields")] + partial class AddComponentLifecycleFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516203715_AddComponentLifecycleFields.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516203715_AddComponentLifecycleFields.cs new file mode 100644 index 0000000..b8fec06 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516203715_AddComponentLifecycleFields.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddComponentLifecycleFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HelmChartName", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmChartVersion", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmRepoUrl", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HelmValues", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledAt", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastError", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Namespace", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReleaseName", + table: "ClusterComponents", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Status", + table: "ClusterComponents", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HelmChartName", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmChartVersion", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmRepoUrl", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "HelmValues", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "InstalledAt", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "LastError", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "Namespace", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "ReleaseName", + table: "ClusterComponents"); + + migrationBuilder.DropColumn( + name: "Status", + table: "ClusterComponents"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516210423_AddExternalRoutes.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516210423_AddExternalRoutes.Designer.cs new file mode 100644 index 0000000..12fb53a --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516210423_AddExternalRoutes.Designer.cs @@ -0,0 +1,1275 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260516210423_AddExternalRoutes")] + partial class AddExternalRoutes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260516210423_AddExternalRoutes.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260516210423_AddExternalRoutes.cs new file mode 100644 index 0000000..12e6d11 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260516210423_AddExternalRoutes.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddExternalRoutes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExternalRoutes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ComponentId = table.Column(type: "TEXT", nullable: false), + Hostname = table.Column(type: "TEXT", maxLength: 253, nullable: false), + ServiceName = table.Column(type: "TEXT", maxLength: 200, nullable: true), + ServicePort = table.Column(type: "INTEGER", nullable: false), + PathPrefix = table.Column(type: "TEXT", maxLength: 200, nullable: false), + TlsMode = table.Column(type: "TEXT", maxLength: 20, nullable: false), + ClusterIssuerName = table.Column(type: "TEXT", maxLength: 200, nullable: true), + TlsCertificate = table.Column(type: "TEXT", nullable: true), + TlsPrivateKey = table.Column(type: "TEXT", nullable: true), + GatewayName = table.Column(type: "TEXT", maxLength: 200, nullable: true), + GatewayNamespace = table.Column(type: "TEXT", maxLength: 63, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExternalRoutes", x => x.Id); + table.ForeignKey( + name: "FK_ExternalRoutes_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalRoutes_ComponentId", + table: "ExternalRoutes", + column: "ComponentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExternalRoutes"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260517142312_AddStorageAndOpenStack.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260517142312_AddStorageAndOpenStack.Designer.cs new file mode 100644 index 0000000..2e3a03c --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260517142312_AddStorageAndOpenStack.Designer.cs @@ -0,0 +1,1434 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260517142312_AddStorageAndOpenStack")] + partial class AddStorageAndOpenStack + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260517142312_AddStorageAndOpenStack.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260517142312_AddStorageAndOpenStack.cs new file mode 100644 index 0000000..daddc34 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260517142312_AddStorageAndOpenStack.cs @@ -0,0 +1,161 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddStorageAndOpenStack : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OpenStackConnectionId", + table: "VaultSecrets", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "StorageLinkId", + table: "VaultSecrets", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "OpenStackConnections", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + AuthUrl = table.Column(type: "TEXT", nullable: false), + Region = table.Column(type: "TEXT", nullable: true), + ProjectName = table.Column(type: "TEXT", nullable: true), + ProjectId = table.Column(type: "TEXT", nullable: true), + UserDomainName = table.Column(type: "TEXT", nullable: true), + ProjectDomainName = table.Column(type: "TEXT", nullable: true), + Username = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenStackConnections", x => x.Id); + table.ForeignKey( + name: "FK_OpenStackConnections_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "StorageLinks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + EnvironmentId = table.Column(type: "TEXT", nullable: false), + Provider = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Endpoint = table.Column(type: "TEXT", nullable: true), + BucketName = table.Column(type: "TEXT", nullable: true), + Region = table.Column(type: "TEXT", nullable: true), + ComponentId = table.Column(type: "TEXT", nullable: true), + OpenStackConnectionId = table.Column(type: "TEXT", nullable: true), + Notes = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StorageLinks", x => x.Id); + table.ForeignKey( + name: "FK_StorageLinks_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_StorageLinks_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageLinks_OpenStackConnections_OpenStackConnectionId", + column: x => x.OpenStackConnectionId, + principalTable: "OpenStackConnections", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_StorageLinks_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_StorageLinkId", + table: "VaultSecrets", + column: "StorageLinkId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenStackConnections_TenantId", + table: "OpenStackConnections", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_ComponentId", + table: "StorageLinks", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_EnvironmentId", + table: "StorageLinks", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_OpenStackConnectionId", + table: "StorageLinks", + column: "OpenStackConnectionId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageLinks_TenantId", + table: "StorageLinks", + column: "TenantId"); + + migrationBuilder.AddForeignKey( + name: "FK_VaultSecrets_StorageLinks_StorageLinkId", + table: "VaultSecrets", + column: "StorageLinkId", + principalTable: "StorageLinks", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VaultSecrets_StorageLinks_StorageLinkId", + table: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "StorageLinks"); + + migrationBuilder.DropTable( + name: "OpenStackConnections"); + + migrationBuilder.DropIndex( + name: "IX_VaultSecrets_StorageLinkId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "OpenStackConnectionId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "StorageLinkId", + table: "VaultSecrets"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260517144048_AddStorageBindings.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260517144048_AddStorageBindings.Designer.cs new file mode 100644 index 0000000..a7c84aa --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260517144048_AddStorageBindings.Designer.cs @@ -0,0 +1,1508 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260517144048_AddStorageBindings")] + partial class AddStorageBindings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260517144048_AddStorageBindings.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260517144048_AddStorageBindings.cs new file mode 100644 index 0000000..f6dfa37 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260517144048_AddStorageBindings.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddStorageBindings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StorageBindings", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + StorageLinkId = table.Column(type: "TEXT", nullable: false), + AppDeploymentId = table.Column(type: "TEXT", nullable: true), + ComponentId = table.Column(type: "TEXT", nullable: true), + KubernetesSecretName = table.Column(type: "TEXT", maxLength: 253, nullable: false), + SyncEnabled = table.Column(type: "INTEGER", nullable: false), + LastSyncedAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StorageBindings", x => x.Id); + table.ForeignKey( + name: "FK_StorageBindings_AppDeployments_AppDeploymentId", + column: x => x.AppDeploymentId, + principalTable: "AppDeployments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageBindings_ClusterComponents_ComponentId", + column: x => x.ComponentId, + principalTable: "ClusterComponents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StorageBindings_StorageLinks_StorageLinkId", + column: x => x.StorageLinkId, + principalTable: "StorageLinks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_AppDeploymentId", + table: "StorageBindings", + column: "AppDeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_ComponentId", + table: "StorageBindings", + column: "ComponentId"); + + migrationBuilder.CreateIndex( + name: "IX_StorageBindings_StorageLinkId", + table: "StorageBindings", + column: "StorageLinkId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StorageBindings"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260517200907_AddCnpgManagement.Designer.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260517200907_AddCnpgManagement.Designer.cs new file mode 100644 index 0000000..bcea884 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260517200907_AddCnpgManagement.Designer.cs @@ -0,0 +1,1711 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260517200907_AddCnpgManagement")] + partial class AddCnpgManagement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("TenantId"); + + b.HasIndex("KubernetesClusterId", "Name", "Namespace") + .IsUnique(); + + b.ToTable("CnpgClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Backups") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster") + .WithMany() + .HasForeignKey("KubernetesClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KubernetesCluster"); + + b.Navigation("StorageLink"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Databases") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase") + .WithMany() + .HasForeignKey("CnpgDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/20260517200907_AddCnpgManagement.cs b/src/EntKube.Web/Data/Migrations/Sqlite/20260517200907_AddCnpgManagement.cs new file mode 100644 index 0000000..7c694d8 --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/20260517200907_AddCnpgManagement.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + /// + public partial class AddCnpgManagement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CnpgDatabaseId", + table: "VaultSecrets", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "CnpgClusters", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + TenantId = table.Column(type: "TEXT", nullable: false), + KubernetesClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 63, nullable: false), + Namespace = table.Column(type: "TEXT", maxLength: 63, nullable: false), + PostgresVersion = table.Column(type: "TEXT", maxLength: 10, nullable: false), + Instances = table.Column(type: "INTEGER", nullable: false), + StorageSize = table.Column(type: "TEXT", maxLength: 20, nullable: false), + StorageLinkId = table.Column(type: "TEXT", nullable: true), + BackupSchedule = table.Column(type: "TEXT", maxLength: 100, nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + LastError = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CnpgClusters", x => x.Id); + table.ForeignKey( + name: "FK_CnpgClusters_KubernetesClusters_KubernetesClusterId", + column: x => x.KubernetesClusterId, + principalTable: "KubernetesClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CnpgClusters_StorageLinks_StorageLinkId", + column: x => x.StorageLinkId, + principalTable: "StorageLinks", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CnpgClusters_Tenants_TenantId", + column: x => x.TenantId, + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CnpgBackups", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CnpgClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 253, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: false), + CompletedAt = table.Column(type: "TEXT", nullable: true), + SizeBytes = table.Column(type: "INTEGER", nullable: true), + LastError = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CnpgBackups", x => x.Id); + table.ForeignKey( + name: "FK_CnpgBackups_CnpgClusters_CnpgClusterId", + column: x => x.CnpgClusterId, + principalTable: "CnpgClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CnpgDatabases", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + CnpgClusterId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 63, nullable: false), + Owner = table.Column(type: "TEXT", maxLength: 63, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CnpgDatabases", x => x.Id); + table.ForeignKey( + name: "FK_CnpgDatabases_CnpgClusters_CnpgClusterId", + column: x => x.CnpgClusterId, + principalTable: "CnpgClusters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VaultSecrets_CnpgDatabaseId", + table: "VaultSecrets", + column: "CnpgDatabaseId"); + + migrationBuilder.CreateIndex( + name: "IX_CnpgBackups_CnpgClusterId_Name", + table: "CnpgBackups", + columns: new[] { "CnpgClusterId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CnpgClusters_KubernetesClusterId_Name_Namespace", + table: "CnpgClusters", + columns: new[] { "KubernetesClusterId", "Name", "Namespace" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CnpgClusters_StorageLinkId", + table: "CnpgClusters", + column: "StorageLinkId"); + + migrationBuilder.CreateIndex( + name: "IX_CnpgClusters_TenantId", + table: "CnpgClusters", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_CnpgDatabases_CnpgClusterId_Name", + table: "CnpgDatabases", + columns: new[] { "CnpgClusterId", "Name" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId", + table: "VaultSecrets", + column: "CnpgDatabaseId", + principalTable: "CnpgDatabases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId", + table: "VaultSecrets"); + + migrationBuilder.DropTable( + name: "CnpgBackups"); + + migrationBuilder.DropTable( + name: "CnpgDatabases"); + + migrationBuilder.DropTable( + name: "CnpgClusters"); + + migrationBuilder.DropIndex( + name: "IX_VaultSecrets_CnpgDatabaseId", + table: "VaultSecrets"); + + migrationBuilder.DropColumn( + name: "CnpgDatabaseId", + table: "VaultSecrets"); + } + } +} diff --git a/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..b84797c --- /dev/null +++ b/src/EntKube.Web/Data/Migrations/Sqlite/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,1708 @@ +// +using System; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntKube.Web.Data.Migrations.Sqlite +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("AppId", "Name") + .IsUnique(); + + b.ToTable("AppDeployments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("AppId", "EnvironmentId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("AppEnvironments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterId") + .HasColumnType("TEXT"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HelmChartName") + .HasColumnType("TEXT"); + + b.Property("HelmChartVersion") + .HasColumnType("TEXT"); + + b.Property("HelmRepoUrl") + .HasColumnType("TEXT"); + + b.Property("HelmValues") + .HasColumnType("TEXT"); + + b.Property("InstalledAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasColumnType("TEXT"); + + b.Property("ReleaseName") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId", "Name") + .IsUnique(); + + b.ToTable("ClusterComponents"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgBackups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupSchedule") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Instances") + .HasColumnType("INTEGER"); + + b.Property("KubernetesClusterId") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("PostgresVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("StorageSize") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("TenantId"); + + b.HasIndex("KubernetesClusterId", "Name", "Namespace") + .IsUnique(); + + b.ToTable("CnpgClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CnpgClusterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CnpgClusterId", "Name") + .IsUnique(); + + b.ToTable("CnpgDatabases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomerId") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("UserId", "CustomerId"); + + b.HasIndex("CustomerId"); + + b.ToTable("CustomerAccesses"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("YamlContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.ToTable("DeploymentManifests"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeploymentId") + .HasColumnType("TEXT"); + + b.Property("Group") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("HealthStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Namespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("ParentResourceId") + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("ParentResourceId"); + + b.ToTable("DeploymentResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClusterIssuerName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GatewayName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("GatewayNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("Hostname") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("PathPrefix") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ServicePort") + .HasColumnType("INTEGER"); + + b.Property("TlsCertificate") + .HasColumnType("TEXT"); + + b.Property("TlsMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TlsPrivateKey") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.ToTable("ExternalRoutes"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApiServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ContextName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Kubeconfig") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectDomainName") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProjectName") + .HasColumnType("TEXT"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("UserDomainName") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("OpenStackConnections"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId") + .IsUnique(); + + b.ToTable("SecretVaults"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppDeploymentId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppDeploymentId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.ToTable("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BucketName") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("EnvironmentId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentId"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("OpenStackConnectionId"); + + b.HasIndex("TenantId"); + + b.ToTable("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "TenantId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.ToTable("TenantMemberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("TenantRoles"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppId") + .HasColumnType("TEXT"); + + b.Property("CnpgDatabaseId") + .HasColumnType("TEXT"); + + b.Property("ComponentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("KubernetesNamespace") + .HasMaxLength(63) + .HasColumnType("TEXT"); + + b.Property("KubernetesSecretName") + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("OpenStackConnectionId") + .HasColumnType("TEXT"); + + b.Property("StorageLinkId") + .HasColumnType("TEXT"); + + b.Property("SyncToKubernetes") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VaultId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppId"); + + b.HasIndex("CnpgDatabaseId"); + + b.HasIndex("ComponentId"); + + b.HasIndex("StorageLinkId"); + + b.HasIndex("VaultId", "AppId", "Name") + .IsUnique(); + + b.HasIndex("VaultId", "ComponentId", "Name") + .IsUnique(); + + b.ToTable("VaultSecrets"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany("Apps") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Deployments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Cluster"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("AppEnvironments") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("AppEnvironments") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster") + .WithMany("Components") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Backups") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.HasOne("EntKube.Web.Data.KubernetesCluster", "KubernetesCluster") + .WithMany() + .HasForeignKey("KubernetesClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KubernetesCluster"); + + b.Navigation("StorageLink"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b => + { + b.HasOne("EntKube.Web.Data.CnpgCluster", "CnpgCluster") + .WithMany("Databases") + .HasForeignKey("CnpgClusterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CnpgCluster"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Customers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b => + { + b.HasOne("EntKube.Web.Data.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Manifests") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Deployment"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "Deployment") + .WithMany("Resources") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.DeploymentResource", "ParentResource") + .WithMany("ChildResources") + .HasForeignKey("ParentResourceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Deployment"); + + b.Navigation("ParentResource"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Environments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("ExternalRoutes") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Groups") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b => + { + b.HasOne("EntKube.Web.Data.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany("KubernetesClusters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("KubernetesClusters") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Environment"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithOne("Vault") + .HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b => + { + b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment") + .WithMany("StorageBindings") + .HasForeignKey("AppDeploymentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("StorageBindings") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany("StorageBindings") + .HasForeignKey("StorageLinkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppDeployment"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany() + .HasForeignKey("ComponentId"); + + b.HasOne("EntKube.Web.Data.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.OpenStackConnection", "OpenStackConnection") + .WithMany("StorageLinks") + .HasForeignKey("OpenStackConnectionId"); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Component"); + + b.Navigation("Environment"); + + b.Navigation("OpenStackConnection"); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b => + { + b.HasOne("EntKube.Web.Data.TenantRole", "Role") + .WithMany("Memberships") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Memberships") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.HasOne("EntKube.Web.Data.Tenant", "Tenant") + .WithMany("Roles") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b => + { + b.HasOne("EntKube.Web.Data.App", "App") + .WithMany("Secrets") + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.CnpgDatabase", "CnpgDatabase") + .WithMany() + .HasForeignKey("CnpgDatabaseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.ClusterComponent", "Component") + .WithMany("Secrets") + .HasForeignKey("ComponentId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("EntKube.Web.Data.StorageLink", "StorageLink") + .WithMany() + .HasForeignKey("StorageLinkId"); + + b.HasOne("EntKube.Web.Data.SecretVault", "Vault") + .WithMany("Secrets") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("App"); + + b.Navigation("CnpgDatabase"); + + b.Navigation("Component"); + + b.Navigation("StorageLink"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EntKube.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EntKube.Web.Data.App", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("Deployments"); + + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b => + { + b.Navigation("Manifests"); + + b.Navigation("Resources"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b => + { + b.Navigation("ExternalRoutes"); + + b.Navigation("Secrets"); + + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b => + { + b.Navigation("Backups"); + + b.Navigation("Databases"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Customer", b => + { + b.Navigation("Apps"); + }); + + modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b => + { + b.Navigation("ChildResources"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Environment", b => + { + b.Navigation("AppEnvironments"); + + b.Navigation("KubernetesClusters"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Group", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b => + { + b.Navigation("StorageLinks"); + }); + + modelBuilder.Entity("EntKube.Web.Data.SecretVault", b => + { + b.Navigation("Secrets"); + }); + + modelBuilder.Entity("EntKube.Web.Data.StorageLink", b => + { + b.Navigation("StorageBindings"); + }); + + modelBuilder.Entity("EntKube.Web.Data.Tenant", b => + { + b.Navigation("Customers"); + + b.Navigation("Environments"); + + b.Navigation("Groups"); + + b.Navigation("KubernetesClusters"); + + b.Navigation("Memberships"); + + b.Navigation("Roles"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("EntKube.Web.Data.TenantRole", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EntKube.Web/Data/OpenStackConnection.cs b/src/EntKube.Web/Data/OpenStackConnection.cs new file mode 100644 index 0000000..4444d0f --- /dev/null +++ b/src/EntKube.Web/Data/OpenStackConnection.cs @@ -0,0 +1,63 @@ +namespace EntKube.Web.Data; + +/// +/// An OpenStack connection stores the authentication details needed to interact +/// with an OpenStack cloud (e.g. Cleura/City Cloud). This enables the platform +/// to manage S3 buckets, credentials, and other resources via the OpenStack API. +/// +/// Credentials (password, application credential secret) are stored encrypted +/// in the vault — only metadata lives here. +/// +public class OpenStackConnection +{ + public Guid Id { get; set; } + + /// The tenant that owns this connection. + public Guid TenantId { get; set; } + + /// Human-friendly name (e.g. "Cleura Production", "City Cloud Dev"). + public required string Name { get; set; } + + /// + /// The Keystone authentication URL. + /// Example: "https://identity.c2.citycloud.com:5000/v3" + /// + public required string AuthUrl { get; set; } + + /// + /// The OpenStack region (e.g. "Kna1", "Sto2", "Fra1"). + /// + public string? Region { get; set; } + + /// + /// The OpenStack project/tenant name. + /// + public string? ProjectName { get; set; } + + /// + /// The OpenStack project/tenant ID. + /// + public string? ProjectId { get; set; } + + /// + /// The user domain name (typically "Default" or the company domain). + /// + public string? UserDomainName { get; set; } + + /// + /// The project domain name (typically "Default"). + /// + public string? ProjectDomainName { get; set; } + + /// + /// The OpenStack username for authentication. + /// The password is stored in the vault under this connection's ID. + /// + public string? Username { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public ICollection StorageLinks { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/SecretVault.cs b/src/EntKube.Web/Data/SecretVault.cs new file mode 100644 index 0000000..f50560d --- /dev/null +++ b/src/EntKube.Web/Data/SecretVault.cs @@ -0,0 +1,31 @@ +namespace EntKube.Web.Data; + +/// +/// A per-tenant secrets vault. Each tenant gets exactly one vault when created. +/// The vault stores an encrypted Data Encryption Key (DEK) that protects all +/// secrets within it. The DEK is sealed with the platform root key — providing +/// automatic unsealing without manual key ceremonies like HashiCorp Vault requires. +/// +public class SecretVault +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + /// + /// The tenant's Data Encryption Key, encrypted (sealed) with the platform root key. + /// Combined format: ciphertext + 16-byte GCM authentication tag. + /// + public required byte[] EncryptedDataKey { get; set; } + + /// + /// The nonce (IV) used when sealing the DEK. Needed for decryption. + /// + public required byte[] Nonce { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public ICollection Secrets { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/StorageBinding.cs b/src/EntKube.Web/Data/StorageBinding.cs new file mode 100644 index 0000000..af1db29 --- /dev/null +++ b/src/EntKube.Web/Data/StorageBinding.cs @@ -0,0 +1,61 @@ +namespace EntKube.Web.Data; + +/// +/// A storage binding connects a StorageLink to a workload — either an AppDeployment +/// or a ClusterComponent. When a binding exists, the platform knows to sync the +/// storage credentials (access key, secret key, endpoint, bucket, region) into a +/// Kubernetes Secret in the workload's namespace so the pods can consume them. +/// +/// Think of it as "this deployment uses that storage bucket." The binding carries +/// the target K8s Secret name so the workload's env vars or volume mounts can +/// reference a well-known secret name regardless of which provider backs it. +/// +public class StorageBinding +{ + public Guid Id { get; set; } + + /// + /// The storage link being bound to the workload. This is where the credentials + /// and connection details (endpoint, bucket, region) live. + /// + public Guid StorageLinkId { get; set; } + + /// + /// If set, this binding targets an app deployment. The secret will be synced + /// to the deployment's namespace on its target cluster. + /// + public Guid? AppDeploymentId { get; set; } + + /// + /// If set, this binding targets a cluster component. The secret will be synced + /// to the component's namespace on its cluster. + /// + public Guid? ComponentId { get; set; } + + /// + /// The Kubernetes Secret name that will hold the storage credentials in the + /// target namespace. Workloads reference this name in their env/volume config. + /// Example: "media-s3-credentials", "backup-storage". + /// + public required string KubernetesSecretName { get; set; } + + /// + /// When true, the platform will automatically create/update the K8s Secret + /// whenever the underlying StorageLink credentials change. When false, the + /// binding exists as metadata only (manual sync or not yet activated). + /// + public bool SyncEnabled { get; set; } = true; + + /// + /// Last time the secret was successfully synced to the target cluster. + /// Null if never synced. + /// + public DateTime? LastSyncedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public StorageLink StorageLink { get; set; } = null!; + public AppDeployment? AppDeployment { get; set; } + public ClusterComponent? Component { get; set; } +} diff --git a/src/EntKube.Web/Data/StorageLink.cs b/src/EntKube.Web/Data/StorageLink.cs new file mode 100644 index 0000000..0742385 --- /dev/null +++ b/src/EntKube.Web/Data/StorageLink.cs @@ -0,0 +1,91 @@ +namespace EntKube.Web.Data; + +/// +/// The cloud provider or platform for a storage link. +/// +public enum StorageProvider +{ + /// MinIO running on the cluster (auto-discovered). + MinIO, + + /// Amazon Web Services S3. + AwsS3, + + /// Microsoft Azure Blob Storage / Storage Account. + AzureStorage, + + /// Cleura (City Cloud) S3-compatible object storage. + CleuraS3 +} + +/// +/// A storage link represents a connection to an object storage provider. +/// It can be an auto-discovered MinIO instance running on a cluster, or an +/// external cloud storage (AWS S3, Azure Storage, Cleura S3) registered +/// manually. External links store their credentials in the vault. +/// +/// This entity is the source of truth for "which storage does this tenant use?" +/// — regardless of where it's hosted. +/// +public class StorageLink +{ + public Guid Id { get; set; } + + /// The tenant that owns this storage link. + public Guid TenantId { get; set; } + + /// The environment this storage is associated with. + public Guid EnvironmentId { get; set; } + + /// Which provider hosts this storage. + public StorageProvider Provider { get; set; } + + /// Human-friendly name for display (e.g. "Production Backups", "Media Assets"). + public required string Name { get; set; } + + /// + /// The endpoint URL for S3-compatible access. + /// For AWS: "https://s3.eu-west-1.amazonaws.com" + /// For MinIO: "http://minio.minio.svc.cluster.local:9000" + /// For Cleura: "https://s3-.cloudferro.com" + /// For Azure: "https://.blob.core.windows.net" + /// + public string? Endpoint { get; set; } + + /// + /// The bucket or container name. + /// For Azure this is the container name within the storage account. + /// + public string? BucketName { get; set; } + + /// + /// Region for the storage (e.g. "eu-west-1", "swedencentral"). + /// + public string? Region { get; set; } + + /// + /// For MinIO links discovered from a cluster, reference the cluster component. + /// Null for external providers. + /// + public Guid? ComponentId { get; set; } + + /// + /// For Cleura S3 links, reference the OpenStack connection used to manage + /// buckets and credentials. Required when Provider is CleuraS3. + /// + public Guid? OpenStackConnectionId { get; set; } + + /// + /// Notes or description for this storage link. + /// + public string? Notes { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public Tenant Tenant { get; set; } = null!; + public Environment Environment { get; set; } = null!; + public ClusterComponent? Component { get; set; } + public OpenStackConnection? OpenStackConnection { get; set; } + public ICollection StorageBindings { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/Tenant.cs b/src/EntKube.Web/Data/Tenant.cs new file mode 100644 index 0000000..acde34e --- /dev/null +++ b/src/EntKube.Web/Data/Tenant.cs @@ -0,0 +1,33 @@ +namespace EntKube.Web.Data; + +/// +/// A tenant represents an organization or workspace in EntKube. Every resource +/// (clusters, services, monitoring) is scoped to a tenant. Users interact with +/// the platform through their tenant memberships. +/// +public class Tenant +{ + public Guid Id { get; set; } + + /// + /// Human-friendly display name for the tenant (e.g. "Acme Corp"). + /// + public required string Name { get; set; } + + /// + /// URL-safe unique identifier used in routes and API calls (e.g. "acme-corp"). + /// Must be unique across all tenants. + /// + public required string Slug { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties — a tenant owns its roles, memberships, groups, environments, customers, clusters, and vault. + public ICollection Roles { get; set; } = []; + public ICollection Memberships { get; set; } = []; + public ICollection Groups { get; set; } = []; + public ICollection Environments { get; set; } = []; + public ICollection Customers { get; set; } = []; + public ICollection KubernetesClusters { get; set; } = []; + public SecretVault? Vault { get; set; } +} diff --git a/src/EntKube.Web/Data/TenantMembership.cs b/src/EntKube.Web/Data/TenantMembership.cs new file mode 100644 index 0000000..2054e71 --- /dev/null +++ b/src/EntKube.Web/Data/TenantMembership.cs @@ -0,0 +1,23 @@ +namespace EntKube.Web.Data; + +/// +/// Represents a user's membership in a tenant, including what role they hold. +/// This is the many-to-many join between Users and Tenants, enriched with +/// role context — a user might be an Administrator in one tenant and a Member +/// in another. +/// +public class TenantMembership +{ + public string UserId { get; set; } = null!; + + public Guid TenantId { get; set; } + + public Guid RoleId { get; set; } + + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public ApplicationUser User { get; set; } = null!; + public Tenant Tenant { get; set; } = null!; + public TenantRole Role { get; set; } = null!; +} diff --git a/src/EntKube.Web/Data/TenantRole.cs b/src/EntKube.Web/Data/TenantRole.cs new file mode 100644 index 0000000..6bdd2a9 --- /dev/null +++ b/src/EntKube.Web/Data/TenantRole.cs @@ -0,0 +1,23 @@ +namespace EntKube.Web.Data; + +/// +/// A role that exists within a specific tenant's scope. Roles define what a +/// user can do within that tenant. Every tenant gets at least an "Administrator" +/// role by default. +/// +public class TenantRole +{ + public Guid Id { get; set; } + + public Guid TenantId { get; set; } + + /// + /// The role name (e.g. "Administrator", "Member", "Viewer"). + /// Must be unique within a tenant. + /// + public required string Name { get; set; } + + // Navigation + public Tenant Tenant { get; set; } = null!; + public ICollection Memberships { get; set; } = []; +} diff --git a/src/EntKube.Web/Data/VaultSecret.cs b/src/EntKube.Web/Data/VaultSecret.cs new file mode 100644 index 0000000..958d142 --- /dev/null +++ b/src/EntKube.Web/Data/VaultSecret.cs @@ -0,0 +1,85 @@ +namespace EntKube.Web.Data; + +/// +/// An individual secret stored in a tenant's vault. The value is encrypted +/// with the tenant's DEK (AES-256-GCM). A secret is scoped to either an App +/// or a ClusterComponent — never both, never neither. +/// +/// When SyncToKubernetes is enabled, the platform will create/update a Kubernetes +/// Secret resource matching KubernetesSecretName in the specified namespace. +/// +public class VaultSecret +{ + public Guid Id { get; set; } + + public Guid VaultId { get; set; } + + /// + /// The secret key name (e.g. "DATABASE_PASSWORD", "API_KEY"). + /// Must be unique within its scope (app or component). + /// + public required string Name { get; set; } + + /// + /// The secret value, encrypted with the tenant's DEK. + /// Combined format: ciphertext + 16-byte GCM authentication tag. + /// + public required byte[] EncryptedValue { get; set; } + + /// + /// The nonce used when encrypting the value. Unique per encryption operation. + /// + public required byte[] Nonce { get; set; } + + /// + /// If set, this secret belongs to a customer app. + /// + public Guid? AppId { get; set; } + + /// + /// If set, this secret belongs to a cluster component. + /// + public Guid? ComponentId { get; set; } + + /// + /// If set, this secret belongs to an external storage link. + /// + public Guid? StorageLinkId { get; set; } + + /// + /// If set, this secret belongs to an OpenStack connection. + /// + public Guid? OpenStackConnectionId { get; set; } + + /// + /// If set, this secret belongs to a CNPG database (connection credentials). + /// + public Guid? CnpgDatabaseId { get; set; } + + /// + /// When true, this secret will be synced to Kubernetes as a Secret resource. + /// + public bool SyncToKubernetes { get; set; } + + /// + /// The target Kubernetes Secret name (e.g. "my-app-secrets"). + /// Multiple vault secrets can target the same K8s Secret as different data keys. + /// + public string? KubernetesSecretName { get; set; } + + /// + /// The target Kubernetes namespace for the synced secret. + /// + public string? KubernetesNamespace { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation + public SecretVault Vault { get; set; } = null!; + public App? App { get; set; } + public ClusterComponent? Component { get; set; } + public StorageLink? StorageLink { get; set; } + public CnpgDatabase? CnpgDatabase { get; set; } +} diff --git a/src/EntKube.Web/Data/app.db b/src/EntKube.Web/Data/app.db index d8ebdd2..0e15870 100644 Binary files a/src/EntKube.Web/Data/app.db and b/src/EntKube.Web/Data/app.db differ diff --git a/src/EntKube.Web/Dockerfile b/src/EntKube.Web/Dockerfile deleted file mode 100644 index de0d5da..0000000 --- a/src/EntKube.Web/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 5000 - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["src/EntKube.Web/EntKube.Web.csproj", "src/EntKube.Web/"] -COPY ["src/EntKube.Web.Client/EntKube.Web.Client.csproj", "src/EntKube.Web.Client/"] -COPY ["src/EntKube.SharedKernel/EntKube.SharedKernel.csproj", "src/EntKube.SharedKernel/"] -RUN dotnet restore "src/EntKube.Web/EntKube.Web.csproj" -COPY . . -WORKDIR "/src/src/EntKube.Web" -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://+:5000 -ENTRYPOINT ["dotnet", "EntKube.Web.dll"] diff --git a/src/EntKube.Web/EntKube.Web.csproj b/src/EntKube.Web/EntKube.Web.csproj index 7fe599b..076255a 100644 --- a/src/EntKube.Web/EntKube.Web.csproj +++ b/src/EntKube.Web/EntKube.Web.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - aspnet-EntKube_Web-e46010b0-5a46-448f-afbc-8bd8311cd127 + aspnet-EntKube_Web-ea23db09-ba7f-4040-8763-fa444f800e95 true @@ -13,6 +13,15 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + @@ -21,8 +30,4 @@ - - - - diff --git a/src/EntKube.Web/Program.cs b/src/EntKube.Web/Program.cs index 2c0b79f..25716e7 100644 --- a/src/EntKube.Web/Program.cs +++ b/src/EntKube.Web/Program.cs @@ -5,6 +5,7 @@ using EntKube.Web.Client.Pages; using EntKube.Web.Components; using EntKube.Web.Components.Account; using EntKube.Web.Data; +using EntKube.Web.Services; namespace EntKube.Web; @@ -14,9 +15,7 @@ public class Program { WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - // Register Blazor components with both server and WebAssembly interactivity. - // This BFF hosts the UI and proxies API calls to the backend microservices. - + // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents() @@ -26,8 +25,6 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); - // Configure ASP.NET Identity authentication using cookie-based schemes. - builder.Services.AddAuthentication(options => { options.DefaultScheme = IdentityConstants.ApplicationScheme; @@ -35,18 +32,54 @@ public class Program }) .AddIdentityCookies(); - // Register the database context. SQLite for local development. + // The database provider is selected based on the "DatabaseProvider" config value. + // Supported values: "Sqlite" (default for local dev), "Postgres", "SqlServer". + // Each provider uses its own connection string and DbContext subclass so that + // migrations are generated and applied independently per provider. + string databaseProvider = builder.Configuration.GetValue("DatabaseProvider") ?? "Sqlite"; string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); - builder.Services.AddDbContext(options => - options.UseSqlite(connectionString)); + // Register both AddDbContext (for Identity/migrations — needs derived type mapping) + // and a DbContext factory for Blazor Server concurrency safety. + // A single scoped DbContext is NOT safe in Blazor Server because multiple + // async operations (different components) can run concurrently on the same circuit. + + switch (databaseProvider) + { + case "Postgres": + builder.Services.AddDbContext(options => + options.UseNpgsql(connectionString)); + builder.Services.AddSingleton>( + _ => new DelegatingDbContextFactory(() => + { + DbContextOptionsBuilder opts = new(); + opts.UseNpgsql(connectionString); + return new PostgresApplicationDbContext(opts.Options); + })); + break; + + case "SqlServer": + builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString)); + builder.Services.AddSingleton>( + _ => new DelegatingDbContextFactory(() => + { + DbContextOptionsBuilder opts = new(); + opts.UseSqlServer(connectionString); + return new SqlServerApplicationDbContext(opts.Options); + })); + break; + + default: // "Sqlite" + builder.Services.AddDbContextFactory(options => + options.UseSqlite(connectionString)); + break; + } builder.Services.AddDatabaseDeveloperPageExceptionFilter(); - // Set up ASP.NET Identity with EF Core stores. - builder.Services.AddIdentityCore(options => { options.SignIn.RequireConfirmedAccount = true; @@ -56,110 +89,43 @@ public class Program .AddSignInManager() .AddDefaultTokenProviders(); - // ConfigureApplicationCookie MUST come after AddIdentityCore + AddSignInManager - // because those calls internally configure cookie options and would overwrite - // earlier event handlers. Override the redirect events so /api/* paths get - // 401/403 JSON instead of a redirect to the login page (which is HTML and - // causes "'<' is an invalid start of a value" in the Blazor WASM deserializer). - - builder.Services.ConfigureApplicationCookie(options => - { - options.Events.OnRedirectToLogin = context => - { - if (context.Request.Path.StartsWithSegments("/api")) - { - context.Response.StatusCode = 401; - context.Response.ContentType = "application/json"; - return context.Response.WriteAsync("{\"success\":false,\"error\":\"Authentication required.\"}"); - } - - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - - options.Events.OnRedirectToAccessDenied = context => - { - if (context.Request.Path.StartsWithSegments("/api")) - { - context.Response.StatusCode = 403; - context.Response.ContentType = "application/json"; - return context.Response.WriteAsync("{\"success\":false,\"error\":\"Access denied.\"}"); - } - - context.Response.Redirect(context.RedirectUri); - return Task.CompletedTask; - }; - }); - builder.Services.AddSingleton, IdentityNoOpEmailSender>(); + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); - // Register HTTP clients for communicating with backend microservices. - // Each service has its own base URL configured via appsettings. - - builder.Services.AddHttpClient("ClustersApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Clusters:BaseUrl"] ?? "https://localhost:5010"); - client.Timeout = TimeSpan.FromMinutes(10); - }); - - builder.Services.AddHttpClient("ProvisioningApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Provisioning:BaseUrl"] ?? "https://localhost:5020"); - client.Timeout = TimeSpan.FromMinutes(10); - }); - - builder.Services.AddHttpClient("IdentityApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Identity:BaseUrl"] ?? "https://localhost:5030"); - }); - - builder.Services.AddHttpClient("SecretsApi", client => - { - client.BaseAddress = new Uri(builder.Configuration["Services:Secrets:BaseUrl"] ?? "https://localhost:5040"); - }); - - // Register a default HttpClient for Blazor interactive components running - // in Server mode (InteractiveAuto). WASM components get their own HttpClient - // from the WASM host, but when the same components run server-side they need - // an HttpClient with BaseAddress pointing back to this server so they can - // call the BFF proxy endpoints (e.g., /api/clusters, /api/vault). - // - // Problem: IHttpContextAccessor.HttpContext is null during Blazor Server's - // SignalR circuit, so the server-side HttpClient can't forward auth cookies - // for loopback calls. Solution: generate a random loopback token at startup. - // The server-side HttpClient sends it as a header; middleware on /api/* - // recognizes it and bypasses cookie auth. This is safe because the token - // is only known to this process and never leaves the server. - - string loopbackToken = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); - builder.Services.AddSingleton(new LoopbackAuthToken(loopbackToken)); - - builder.Services.AddHttpContextAccessor(); - - builder.Services.AddScoped(sp => - { - LoopbackAuthToken token = sp.GetRequiredService(); - - HttpClientHandler innerHandler = new() { UseCookies = false }; - LoopbackTokenHandler handler = new(innerHandler, token.Token); - - return new HttpClient(handler) - { - BaseAddress = new Uri("http://localhost:5000"), - Timeout = TimeSpan.FromMinutes(15) - }; - }); - - builder.Services.AddHealthChecks(); + // Vault encryption: the root key is loaded from configuration. + // In production this should come from a secure source (env var, key vault, etc.) + string rootKeyBase64 = builder.Configuration.GetValue("Vault:RootKey") + ?? throw new InvalidOperationException("Vault:RootKey must be configured (32-byte base64-encoded key)."); + byte[] rootKey = Convert.FromBase64String(rootKeyBase64); + builder.Services.AddSingleton(new VaultEncryptionService(rootKey)); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); WebApplication app = builder.Build(); - // Apply pending database migrations on startup with retry logic. + // Apply pending migrations automatically on startup. + // In a containerized or deployed environment the database may not be + // immediately reachable, so we retry with exponential backoff. - MigrateDatabase(app); + using (IServiceScope scope = app.Services.CreateScope()) + { + ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); + MigrateWithRetry(db, app.Logger); + } // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); @@ -167,140 +133,35 @@ public class Program } else { - app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseExceptionHandler("/Error"); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } - // Return HTML error pages only for non-API routes. API routes (/api/*) - // must always return JSON — without this check, bare status codes from - // proxy endpoints get re-executed as "/not-found" HTML, which causes - // "'<' is an invalid start of a value" errors in the Blazor WASM client. - - app.UseStatusCodePages(context => - { - if (context.HttpContext.Request.Path.StartsWithSegments("/api")) - { - return Task.CompletedTask; - } - - context.HttpContext.Response.Redirect($"/not-found"); - return Task.CompletedTask; - }); - - // Catch unhandled exceptions on /api/* routes and return a JSON error - // instead of an HTML error page. Backend services may be unreachable - // during local development, and HttpRequestException must not propagate - // into the Blazor deserializer. - - app.Use(async (context, next) => - { - if (context.Request.Path.StartsWithSegments("/api")) - { - try - { - await next(); - } - catch (HttpRequestException) - { - context.Response.StatusCode = 502; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsync( - "{\"success\":false,\"error\":\"Backend service is unreachable. Make sure all required services are running.\"}"); - } - } - else - { - await next(); - } - }); - + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseHttpsRedirection(); - // Explicitly call UseAuthentication so that auto-insertion is disabled. - // This lets us place our loopback middleware AFTER authentication - // (which populates context.User from cookies) but BEFORE authorization - // (which checks context.User against [Authorize] / RequireAuthorization). - - app.UseAuthentication(); - - // Loopback auth bypass: when Blazor Server components make HTTP calls - // back to this server's /api/* endpoints, they can't forward the user's - // auth cookie (HttpContext is null in SignalR circuits). Instead, the - // server-side HttpClient sends a secret loopback token. This middleware - // recognizes that token and overrides the unauthenticated principal - // with a trusted identity. The token is generated at startup and never - // leaves the process. - - LoopbackAuthToken loopbackAuthToken = app.Services.GetRequiredService(); - - app.Use(async (context, next) => - { - if (context.Request.Path.StartsWithSegments("/api") - && context.Request.Headers.TryGetValue("X-Loopback-Token", out Microsoft.Extensions.Primitives.StringValues tokenValue) - && tokenValue == loopbackAuthToken.Token) - { - // Trusted server-to-self call — override the unauthenticated - // principal that UseAuthentication set (no cookie was present) - // with a dummy identity that UseAuthorization will accept. - - System.Security.Claims.ClaimsIdentity identity = new("LoopbackAuth"); - identity.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "blazor-server")); - context.User = new System.Security.Claims.ClaimsPrincipal(identity); - } - - await next(); - }); - - app.UseAuthorization(); - app.UseAntiforgery(); app.MapStaticAssets(); - - app.MapRazorComponents() + app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(EntKube.Web.Client._Imports).Assembly); + .AddAdditionalAssemblies(typeof(Client._Imports).Assembly); // Add additional endpoints required by the Identity /Account Razor components. - app.MapAdditionalIdentityEndpoints(); - // Map BFF proxy endpoints that forward requests to backend microservices. - - EntKube.Web.Components.Clusters.ClustersProxyEndpoints.Map(app); - EntKube.Web.Components.Clusters.CertificateProxyEndpoints.Map(app); - EntKube.Web.Components.Identity.TenantsProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.ServicesProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.MinioTenantProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.KeycloakRealmProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.PostgresClusterProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.RedisClusterProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.MongoClusterProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.PrometheusProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.GrafanaProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.AppsProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.HarborProxyEndpoints.Map(app); - EntKube.Web.Components.Provisioning.GiteaProxyEndpoints.Map(app); - EntKube.Web.Components.Secrets.SecretsProxyEndpoints.Map(app); - - app.MapHealthChecks("/health"); - app.MapHealthChecks("/health/ready"); - app.Run(); } /// - /// Applies any pending EF Core migrations when the application starts. - /// Retries with exponential backoff for containerized environments where - /// the database may not be immediately available. + /// Attempts to apply pending EF Core migrations, retrying up to 5 times with + /// exponential backoff. This handles the common scenario in containerized + /// deployments where the database container isn't ready when the app starts. /// - private static void MigrateDatabase(WebApplication app) + private static void MigrateWithRetry(DbContext db, ILogger logger) { - using IServiceScope scope = app.Services.CreateScope(); - ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); - ILogger logger = scope.ServiceProvider.GetRequiredService>(); - int maxRetries = 5; int delayMs = 1000; @@ -309,18 +170,12 @@ public class Program try { db.Database.Migrate(); - logger.LogInformation("Database migrations applied successfully on attempt {Attempt}.", attempt); + logger.LogInformation("Database migrations applied successfully."); return; } catch (Exception ex) when (attempt < maxRetries) { - logger.LogWarning( - ex, - "Database migration attempt {Attempt}/{MaxRetries} failed. Retrying in {DelayMs}ms...", - attempt, - maxRetries, - delayMs); - + logger.LogWarning(ex, "Migration attempt {Attempt} failed. Retrying in {Delay}ms...", attempt, delayMs); Thread.Sleep(delayMs); delayMs *= 2; } @@ -329,31 +184,11 @@ public class Program } /// -/// Holds the randomly-generated loopback token. Registered as singleton so the -/// same token is shared between the middleware (validation) and the HttpClient -/// handler (sending). The token is generated at startup and never persisted. +/// A simple IDbContextFactory adapter that uses a delegate to create DbContext instances. +/// Needed because AddDbContextFactory doesn't support derived DbContext types the way +/// AddDbContext does with its TContext/TImplementation overload. /// -internal sealed record LoopbackAuthToken(string Token); - -/// -/// Attaches the loopback token to every outgoing request from the server-side -/// HttpClient. The middleware recognizes this token and bypasses cookie auth, -/// allowing Blazor Server components to call /api/* endpoints without cookies. -/// -internal sealed class LoopbackTokenHandler : DelegatingHandler +file sealed class DelegatingDbContextFactory(Func factory) : IDbContextFactory { - private readonly string token; - - public LoopbackTokenHandler(HttpMessageHandler innerHandler, string token) - : base(innerHandler) - { - this.token = token; - } - - protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - request.Headers.TryAddWithoutValidation("X-Loopback-Token", token); - return base.SendAsync(request, cancellationToken); - } + public ApplicationDbContext CreateDbContext() => factory(); } diff --git a/src/EntKube.Web/Properties/launchSettings.json b/src/EntKube.Web/Properties/launchSettings.json index b01bd85..a8e8d48 100644 --- a/src/EntKube.Web/Properties/launchSettings.json +++ b/src/EntKube.Web/Properties/launchSettings.json @@ -1,25 +1,25 @@ -{ +{ "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7000;http://localhost:5000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5029", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7136;http://localhost:5029", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } } } } -} diff --git a/src/EntKube.Web/Services/CnpgService.cs b/src/EntKube.Web/Services/CnpgService.cs new file mode 100644 index 0000000..6ed55c8 --- /dev/null +++ b/src/EntKube.Web/Services/CnpgService.cs @@ -0,0 +1,1043 @@ +using System.Security.Cryptography; +using System.Text; +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Manages the full lifecycle of CloudNativePG clusters — from creation through +/// backup, restore, upgrade, database management, and deletion. Each operation +/// translates high-level intent into Kubernetes CRD manifests applied to the +/// cluster where the CNPG operator is running. +/// +/// The service owns the CnpgCluster, CnpgDatabase, and CnpgBackup records in the +/// database. It coordinates with VaultService to store database credentials and +/// tag them for Kubernetes sync so applications can consume them as K8s Secrets. +/// +public class CnpgService( + IDbContextFactory dbFactory, + VaultService vaultService, + IKubernetesClientFactory k8sFactory) +{ + // ──────── Cluster Lifecycle ──────── + + /// + /// Creates a new managed CNPG cluster. First validates that the CNPG operator + /// is installed on the target cluster, then generates the Cluster CRD manifest + /// and applies it to Kubernetes. If a storage link is provided, configures + /// Barman backup with WAL archiving to the selected S3 bucket. + /// + public async Task CreateClusterAsync( + Guid tenantId, + Guid kubernetesClusterId, + string name, + string ns, + int instances, + string storageSize, + Guid? storageLinkId, + string? backupSchedule, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Verify the CNPG operator is installed on the target cluster. + + bool operatorInstalled = await db.ClusterComponents + .AnyAsync(c => c.ClusterId == kubernetesClusterId + && c.Name == "cloudnative-pg" + && c.Status == ComponentStatus.Installed, ct); + + if (!operatorInstalled) + { + throw new InvalidOperationException( + "CloudNativePG operator is not installed on this cluster. Install it first from the Components tab."); + } + + // Load the cluster's kubeconfig for applying the manifest. + + KubernetesCluster k8sCluster = await db.KubernetesClusters + .FirstAsync(k => k.Id == kubernetesClusterId, ct); + + // Load backup storage details if configured. + + StorageLink? storageLink = null; + + if (storageLinkId.HasValue) + { + storageLink = await db.StorageLinks + .FirstOrDefaultAsync(s => s.Id == storageLinkId.Value && s.TenantId == tenantId, ct); + } + + // Create the database record. + + CnpgCluster cnpgCluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + KubernetesClusterId = kubernetesClusterId, + Name = name, + Namespace = ns, + PostgresVersion = "18", + Instances = instances, + StorageSize = storageSize, + StorageLinkId = storageLinkId, + BackupSchedule = backupSchedule, + Status = CnpgClusterStatus.Creating + }; + + db.CnpgClusters.Add(cnpgCluster); + await db.SaveChangesAsync(ct); + + // If backup storage is configured, ensure the S3 credentials are synced + // to Kubernetes so the CNPG operator can access the backup bucket. + + string? s3SecretName = null; + + if (storageLink is not null) + { + s3SecretName = $"{name}-s3-credentials"; + + await EnsureStorageSecretsInK8sAsync( + tenantId, storageLink, s3SecretName, ns, ct); + } + + // Generate and apply the CNPG Cluster manifest. + + string manifest = BuildClusterManifest(cnpgCluster, storageLink, s3SecretName); + + // Ensure the target namespace exists before applying CNPG resources. + + await k8sFactory.EnsureNamespaceAsync(ns, k8sCluster.Kubeconfig!, ct); + await k8sFactory.ApplyManifestAsync(manifest, k8sCluster.Kubeconfig!, ct); + + // If backup storage is configured, apply the ObjectStore CRD separately. + // The ObjectStore requires the Barman Cloud Plugin to be installed — if it + // isn't, we still create the cluster but surface a clear error about backups. + + if (storageLink is not null && s3SecretName is not null) + { + string objectStoreManifest = BuildObjectStoreManifest(name, ns, storageLink, s3SecretName); + + try + { + await k8sFactory.ApplyManifestAsync(objectStoreManifest, k8sCluster.Kubeconfig!, ct); + } + catch (InvalidOperationException) + { + throw new InvalidOperationException( + "Cluster created but backup configuration failed — the Barman Cloud Plugin " + + "is not installed. Install 'barman-cloud-plugin' from the component catalog, " + + "then re-save the cluster to apply backup settings."); + } + } + + // If a backup schedule is configured, append a ScheduledBackup resource. + + if (!string.IsNullOrWhiteSpace(backupSchedule) && storageLink is not null) + { + string scheduledBackupManifest = BuildScheduledBackupManifest(name, ns, backupSchedule); + await k8sFactory.ApplyManifestAsync(scheduledBackupManifest, k8sCluster.Kubeconfig!, ct); + } + + return cnpgCluster; + } + + /// + /// Deletes a managed CNPG cluster from Kubernetes and removes all associated + /// records (databases, backups, vault secrets). + /// + public async Task DeleteClusterAsync(Guid tenantId, Guid cnpgClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.Databases) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + // Attempt to delete the K8s resources. If the cluster never actually deployed + // or the K8s API is unreachable, we still proceed with removing the DB record + // so the user isn't stuck with an un-deletable entry. + + try + { + await k8sFactory.DeleteManifestAsync( + "clusters.postgresql.cnpg.io", cnpg.Name, cnpg.Namespace, + cnpg.KubernetesCluster.Kubeconfig!, ct); + + await k8sFactory.DeleteManifestAsync( + "objectstores.barmancloud.cnpg.io", $"{cnpg.Name}-object-store", + cnpg.Namespace, cnpg.KubernetesCluster.Kubeconfig!, ct); + + if (!string.IsNullOrWhiteSpace(cnpg.BackupSchedule)) + { + await k8sFactory.DeleteManifestAsync( + "scheduledbackups.postgresql.cnpg.io", $"{cnpg.Name}-scheduled", + cnpg.Namespace, cnpg.KubernetesCluster.Kubeconfig!, ct); + } + } + catch (Exception) + { + // K8s operations failed — the cluster may never have been created, + // the kubeconfig may be invalid, or the API server is unreachable. + // We proceed with local cleanup regardless. + } + + // Remove vault secrets for all databases in this cluster. + + foreach (CnpgDatabase database in cnpg.Databases) + { + await DeleteDatabaseSecretsAsync(tenantId, database.Id, ct); + } + + // Remove the cluster record (cascades to databases and backups). + + db.CnpgClusters.Remove(cnpg); + await db.SaveChangesAsync(ct); + } + + /// + /// Upgrades a CNPG cluster to a new PostgreSQL major version. + /// Updates the imageName in the Cluster CRD — CNPG handles the rolling upgrade. + /// + public async Task UpgradeClusterAsync( + Guid tenantId, Guid cnpgClusterId, string targetVersion, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + // CloudNativePG supports zero-downtime rolling updates for minor version + // changes (e.g. 16.2 → 16.4). Major version upgrades (e.g. 16 → 17) require + // a new cluster + restore because PostgreSQL data formats are incompatible. + // We block major version jumps here—use restore-to-new-cluster instead. + + int currentMajor = ExtractMajorVersion(cnpg.PostgresVersion); + int targetMajor = ExtractMajorVersion(targetVersion); + + if (targetMajor != currentMajor) + { + throw new InvalidOperationException( + $"Major version upgrades ({currentMajor} → {targetMajor}) are not supported in-place. " + + "Use point-in-time restore to create a new cluster with the target version."); + } + + cnpg.PostgresVersion = targetVersion; + cnpg.Status = CnpgClusterStatus.Upgrading; + await db.SaveChangesAsync(ct); + + // Re-apply the manifest with the updated version. CNPG will perform a rolling + // update: replicas first, then switchover to a promoted replica, then update + // the old primary—zero downtime for clients using the -rw service endpoint. + + string s3SecretName = cnpg.StorageLinkId.HasValue ? $"{cnpg.Name}-s3-credentials" : null!; + string manifest = BuildClusterManifest(cnpg, cnpg.StorageLink, s3SecretName); + + await k8sFactory.ApplyManifestAsync(manifest, cnpg.KubernetesCluster.Kubeconfig!, ct); + } + + /// + /// Performs a major version upgrade by restoring to a new cluster with the target + /// PostgreSQL version, then swapping the service name so existing clients reconnect + /// automatically, and finally removing the old cluster. + /// + /// The flow is: + /// 1. Take a fresh backup of the source cluster (ensures we have the latest WALs). + /// 2. Create a new cluster with the target version bootstrapping from that backup. + /// 3. Once the new cluster is ready, rename it to take over the original name. + /// K8s services like "{name}-rw" will then point to the new cluster—zero DNS changes needed. + /// 4. Delete the old cluster resources from Kubernetes. + /// 5. Transfer all database records and vault secrets to the new cluster. + /// + /// NOTE: There is a brief window of downtime (~10-30s) between deleting the old cluster + /// and the new cluster's services becoming available under the original name. For true + /// zero-downtime major upgrades, use logical replication instead. + /// + public async Task MajorUpgradeAsync( + Guid tenantId, Guid sourceCnpgClusterId, string targetVersion, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster source = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .Include(c => c.Databases) + .FirstOrDefaultAsync(c => c.Id == sourceCnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Source CNPG cluster not found."); + + if (source.StorageLink is null) + { + throw new InvalidOperationException( + "Major version upgrade requires backup storage. Assign an S3 bucket first."); + } + + int sourceMajor = ExtractMajorVersion(source.PostgresVersion); + int targetMajor = ExtractMajorVersion(targetVersion); + + if (targetMajor <= sourceMajor) + { + throw new InvalidOperationException( + $"Target version ({targetVersion}) must be a higher major version than current ({source.PostgresVersion})."); + } + + // Step 1: Take a fresh backup so the restore includes the latest data. + + source.Status = CnpgClusterStatus.Upgrading; + await db.SaveChangesAsync(ct); + + string backupName = $"{source.Name}-pre-upgrade-{DateTime.UtcNow:yyyyMMddHHmmss}"; + string backupManifest = BuildBackupManifest(backupName, source.Name, source.Namespace); + await k8sFactory.ApplyManifestAsync(backupManifest, source.KubernetesCluster.Kubeconfig!, ct); + + // Step 2: Create the new cluster with a temporary name, targeting the new major version. + // It bootstraps from the source's Barman backup (latest point in time). + + string tempName = $"{source.Name}-v{targetMajor}"; + + CnpgCluster upgraded = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + KubernetesClusterId = source.KubernetesClusterId, + Name = tempName, + Namespace = source.Namespace, + PostgresVersion = targetVersion, + Instances = source.Instances, + StorageSize = source.StorageSize, + StorageLinkId = source.StorageLinkId, + BackupSchedule = source.BackupSchedule, + Status = CnpgClusterStatus.Restoring + }; + + db.CnpgClusters.Add(upgraded); + await db.SaveChangesAsync(ct); + + string s3SecretName = $"{tempName}-s3-credentials"; + await EnsureStorageSecretsInK8sAsync( + tenantId, source.StorageLink, s3SecretName, source.Namespace, ct); + + // Use current time as recovery target — we want the very latest state. + + string restoreManifest = BuildRestoreManifest(upgraded, source, DateTime.UtcNow, s3SecretName); + restoreManifest += "\n---\n" + BuildObjectStoreManifest(tempName, source.Namespace, source.StorageLink, s3SecretName); + await k8sFactory.ApplyManifestAsync(restoreManifest, source.KubernetesCluster.Kubeconfig!, ct); + + // Step 3: Delete the old cluster from Kubernetes to free the name. + + await k8sFactory.DeleteManifestAsync( + "Cluster", source.Name, source.Namespace, source.KubernetesCluster.Kubeconfig!, ct); + + // Step 4: Transfer databases and remove the old cluster record from the DB. + // Must happen before renaming the new cluster, because the unique index + // (KubernetesClusterId, Name, Namespace) would conflict otherwise. + + foreach (CnpgDatabase database in source.Databases) + { + database.CnpgClusterId = upgraded.Id; + } + + db.CnpgClusters.Remove(source); + await db.SaveChangesAsync(ct); + + // Step 5: Rename the new cluster to the original name so that the K8s services + // ({name}-rw, {name}-ro, {name}-r) resolve to the upgraded cluster. + // CNPG doesn't support renaming a running cluster, so we delete the temp + // and re-apply with the original name. + + await k8sFactory.DeleteManifestAsync( + "Cluster", tempName, source.Namespace, source.KubernetesCluster.Kubeconfig!, ct); + + upgraded.Name = source.Name; + upgraded.Status = CnpgClusterStatus.Running; + await db.SaveChangesAsync(ct); + + string s3SecretFinal = $"{source.Name}-s3-credentials"; + string finalManifest = BuildClusterManifest(upgraded, source.StorageLink, s3SecretFinal); + finalManifest += "\n---\n" + BuildObjectStoreManifest(source.Name, source.Namespace, source.StorageLink, s3SecretFinal); + await k8sFactory.ApplyManifestAsync(finalManifest, source.KubernetesCluster.Kubeconfig!, ct); + + return upgraded; + } + + // ──────── Backup & Restore ──────── + + /// + /// Triggers an on-demand backup of a CNPG cluster. Creates a Backup CR + /// in Kubernetes that tells Barman to take a base backup to the S3 bucket. + /// + public async Task BackupAsync(Guid tenantId, Guid cnpgClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + if (!cnpg.StorageLinkId.HasValue) + { + throw new InvalidOperationException( + "Backup storage is not configured for this cluster. Assign an S3 bucket first."); + } + + // Generate a unique backup name with timestamp. + + string backupName = $"{cnpg.Name}-{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + + CnpgBackup backup = new() + { + Id = Guid.NewGuid(), + CnpgClusterId = cnpg.Id, + Name = backupName, + Type = CnpgBackupType.OnDemand, + Status = CnpgBackupStatus.Running + }; + + db.CnpgBackups.Add(backup); + await db.SaveChangesAsync(ct); + + // Apply the Backup CR to trigger Barman. + + string manifest = BuildBackupManifest(backupName, cnpg.Name, cnpg.Namespace); + await k8sFactory.ApplyManifestAsync(manifest, cnpg.KubernetesCluster.Kubeconfig!, ct); + + return backup; + } + + /// + /// Performs a point-in-time restore by creating a new CNPG cluster that bootstraps + /// from the source cluster's Barman backup. The new cluster recovers WALs up to + /// the specified target time, giving you a consistent snapshot of that moment. + /// + public async Task RestoreAsync( + Guid tenantId, Guid sourceCnpgClusterId, string newClusterName, + DateTime targetTime, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster source = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .FirstOrDefaultAsync(c => c.Id == sourceCnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("Source CNPG cluster not found."); + + if (source.StorageLink is null) + { + throw new InvalidOperationException( + "Source cluster has no backup storage configured. Cannot restore without backups."); + } + + // Create a new cluster record for the restored instance. + + CnpgCluster restored = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + KubernetesClusterId = source.KubernetesClusterId, + Name = newClusterName, + Namespace = source.Namespace, + PostgresVersion = source.PostgresVersion, + Instances = source.Instances, + StorageSize = source.StorageSize, + StorageLinkId = source.StorageLinkId, + BackupSchedule = source.BackupSchedule, + Status = CnpgClusterStatus.Restoring + }; + + db.CnpgClusters.Add(restored); + await db.SaveChangesAsync(ct); + + // Generate a recovery manifest that bootstraps from the source's Barman backup + // using the Barman Cloud Plugin. We create an ObjectStore for the new cluster + // so it can continue WAL archiving after recovery completes. + + string s3SecretName = $"{newClusterName}-s3-credentials"; + await EnsureStorageSecretsInK8sAsync( + tenantId, source.StorageLink, s3SecretName, source.Namespace, ct); + + string manifest = BuildRestoreManifest(restored, source, targetTime, s3SecretName); + manifest += "\n---\n" + BuildObjectStoreManifest(newClusterName, source.Namespace, source.StorageLink, s3SecretName); + await k8sFactory.ApplyManifestAsync(manifest, source.KubernetesCluster.Kubeconfig!, ct); + + return restored; + } + + // ──────── Database Management ──────── + + /// + /// Creates a new database within a running CNPG cluster. Generates a random + /// password, runs CREATE ROLE + CREATE DATABASE on the primary, then stores + /// the connection credentials in the vault tagged for Kubernetes sync. + /// + public async Task CreateDatabaseAsync( + Guid tenantId, Guid cnpgClusterId, string databaseName, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + string owner = $"{databaseName}_owner"; + string password = GeneratePassword(); + + // Create the database record. + + CnpgDatabase database = new() + { + Id = Guid.NewGuid(), + CnpgClusterId = cnpg.Id, + Name = databaseName, + Owner = owner, + Status = CnpgDatabaseStatus.Creating + }; + + db.CnpgDatabases.Add(database); + await db.SaveChangesAsync(ct); + + // Execute SQL on the primary to create the role and database. + + string sql = $""" + CREATE ROLE "{owner}" WITH LOGIN PASSWORD '{password}'; + CREATE DATABASE "{databaseName}" OWNER "{owner}"; + """; + + await k8sFactory.ExecuteSqlAsync( + cnpg.Name, cnpg.Namespace, sql, cnpg.KubernetesCluster.Kubeconfig!, ct); + + // Mark as ready. + + database.Status = CnpgDatabaseStatus.Ready; + await db.SaveChangesAsync(ct); + + // Store connection credentials in the vault, tagged for K8s sync. + // The K8s Secret will be named "{cluster}-{database}-credentials" in the + // cluster's namespace, containing all fields apps need to connect. + + string k8sSecretName = $"{cnpg.Name}-{databaseName}-credentials"; + string host = $"{cnpg.Name}-rw.{cnpg.Namespace}.svc.cluster.local"; + + await vaultService.InitializeVaultAsync(tenantId, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "HOST", host, k8sSecretName, cnpg.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "PORT", "5432", k8sSecretName, cnpg.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "DATABASE", databaseName, k8sSecretName, cnpg.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "USERNAME", owner, k8sSecretName, cnpg.Namespace, ct); + await StoreDatabaseSecretAsync(tenantId, database.Id, "PASSWORD", password, k8sSecretName, cnpg.Namespace, ct); + + return database; + } + + /// + /// Deletes a database from a CNPG cluster. Drops the database and role + /// from PostgreSQL and removes all associated vault secrets. + /// + public async Task DeleteDatabaseAsync( + Guid tenantId, Guid cnpgClusterId, Guid databaseId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster cnpg = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("CNPG cluster not found."); + + CnpgDatabase database = await db.CnpgDatabases + .FirstOrDefaultAsync(d => d.Id == databaseId && d.CnpgClusterId == cnpg.Id, ct) + ?? throw new InvalidOperationException("Database not found."); + + // Drop the database and role from PostgreSQL. + + string sql = $""" + DROP DATABASE IF EXISTS "{database.Name}"; + DROP ROLE IF EXISTS "{database.Owner}"; + """; + + await k8sFactory.ExecuteSqlAsync( + cnpg.Name, cnpg.Namespace, sql, cnpg.KubernetesCluster.Kubeconfig!, ct); + + // Remove vault secrets. + + await DeleteDatabaseSecretsAsync(tenantId, database.Id, ct); + + // Remove the database record. + + db.CnpgDatabases.Remove(database); + await db.SaveChangesAsync(ct); + } + + // ──────── Queries ──────── + + /// + /// Gets all managed CNPG clusters for a tenant, including their databases. + /// + public async Task> GetClustersAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .ThenInclude(k => k.Environment) + .Include(c => c.StorageLink) + .Include(c => c.Databases) + .Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(5)) + .Where(c => c.TenantId == tenantId) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + /// + /// Gets a single managed CNPG cluster by ID. + /// + public async Task GetClusterAsync(Guid tenantId, Guid cnpgClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .ThenInclude(k => k.Environment) + .Include(c => c.StorageLink) + .Include(c => c.Databases) + .Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(10)) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct); + } + + /// + /// Gets the full detail for a CNPG cluster including live pod status from K8s. + /// Queries the Cluster CRD status for phase/primary info and lists pods to + /// show which is primary vs replica, their readiness, and replication lag. + /// + public async Task GetClusterDetailAsync( + Guid tenantId, Guid cnpgClusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CnpgCluster? cluster = await db.CnpgClusters + .Include(c => c.KubernetesCluster) + .Include(c => c.StorageLink) + .Include(c => c.Databases) + .Include(c => c.Backups.OrderByDescending(b => b.StartedAt).Take(20)) + .FirstOrDefaultAsync(c => c.Id == cnpgClusterId && c.TenantId == tenantId, ct); + + if (cluster is null) + { + return null; + } + + CnpgClusterDetail detail = new() + { + Cluster = cluster, + Phase = "Querying..." + }; + + try + { + // Query the CNPG Cluster CRD status for phase and primary info. + + string clusterJson = await k8sFactory.GetJsonAsync( + $"cluster.postgresql.cnpg.io/{cluster.Name}", + cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, ct: ct); + + ParseClusterStatus(clusterJson, detail); + + // Query pods belonging to this CNPG cluster via label selector. + + string podsJson = await k8sFactory.GetJsonAsync( + "pods", cluster.Namespace, cluster.KubernetesCluster.Kubeconfig!, + $"cnpg.io/cluster={cluster.Name}", ct); + + detail.Pods = ParsePodList(podsJson, detail.CurrentPrimary); + } + catch (Exception) + { + // If K8s is unreachable, we still return the DB record with empty pod info. + detail.Phase = "Unable to reach cluster"; + } + + return detail; + } + + // ──────── Private Helpers ──────── + + /// + /// Ensures the S3 storage credentials are available as a Kubernetes Secret + /// in the target namespace so CNPG's Barman can access the backup bucket. + /// + private async Task EnsureStorageSecretsInK8sAsync( + Guid tenantId, StorageLink storageLink, string secretName, string ns, CancellationToken ct) + { + // Tag the vault secrets for sync. The sync controller will create the K8s Secret. + + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, storageLink.Id, ct); + + foreach (VaultSecret secret in secrets) + { + if (secret.Name == "ACCESS_KEY" || secret.Name == "SECRET_KEY") + { + await vaultService.ConfigureKubernetesSyncAsync( + secret.Id, true, secretName, ns, ct); + } + } + } + + /// + /// Stores a single database credential in the vault with K8s sync configuration. + /// + private async Task StoreDatabaseSecretAsync( + Guid tenantId, Guid databaseId, string name, string value, + string k8sSecretName, string k8sNamespace, CancellationToken ct) + { + await vaultService.SetCnpgDatabaseSecretAsync( + tenantId, databaseId, name, value, k8sSecretName, k8sNamespace, ct); + } + + /// + /// Removes all vault secrets associated with a CNPG database. + /// + private async Task DeleteDatabaseSecretsAsync(Guid tenantId, Guid databaseId, CancellationToken ct) + { + using ApplicationDbContext secretDb = dbFactory.CreateDbContext(); + + List secrets = await secretDb.VaultSecrets + .Where(s => s.CnpgDatabaseId == databaseId) + .ToListAsync(ct); + + secretDb.VaultSecrets.RemoveRange(secrets); + await secretDb.SaveChangesAsync(ct); + } + + /// + /// Generates a secure random password for database roles. + /// + private static string GeneratePassword() + { + byte[] bytes = RandomNumberGenerator.GetBytes(24); + return Convert.ToBase64String(bytes).Replace("+", "x").Replace("/", "y")[..32]; + } + + // ──────── Manifest Builders ──────── + + /// + /// Extracts the major version number from a PostgreSQL version string. + /// Handles formats like "18", "18.1", "16.4-1" etc. + /// + private static int ExtractMajorVersion(string version) + { + string majorPart = version.Split('.')[0].Split('-')[0]; + return int.Parse(majorPart); + } + + /// + /// Builds the CNPG Cluster CRD manifest. Includes Barman backup configuration + /// if a storage link is configured. + /// + private static string BuildClusterManifest(CnpgCluster cnpg, StorageLink? storageLink, string? s3SecretName) + { + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: postgresql.cnpg.io/v1"); + sb.AppendLine("kind: Cluster"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {cnpg.Name}"); + sb.AppendLine($" namespace: {cnpg.Namespace}"); + sb.AppendLine("spec:"); + sb.AppendLine($" instances: {cnpg.Instances}"); + sb.AppendLine($" imageName: ghcr.io/cloudnative-pg/postgresql:{cnpg.PostgresVersion}"); + + // Rolling update strategy: replicas are updated first, then a switchover + // promotes a replica to primary before updating the old primary. This gives + // zero-downtime for minor version changes and config updates. + + sb.AppendLine(" primaryUpdateStrategy: unsupervised"); + sb.AppendLine(" primaryUpdateMethod: switchover"); + sb.AppendLine(" switchoverDelay: 600"); + + // Synchronous replication requires at least 2 instances. For HA clusters, + // we guarantee one synchronous replica so no committed transactions are lost. + + if (cnpg.Instances > 1) + { + sb.AppendLine(" minSyncReplicas: 1"); + sb.AppendLine(" maxSyncReplicas: 1"); + } + + sb.AppendLine(" postgresql:"); + sb.AppendLine(" parameters:"); + sb.AppendLine(" shared_buffers: \"256MB\""); + sb.AppendLine(" max_connections: \"200\""); + sb.AppendLine(" storage:"); + sb.AppendLine($" size: {cnpg.StorageSize}"); + + // Use the Barman Cloud Plugin for backup/WAL archiving instead of the + // deprecated native spec.backup.barmanObjectStore approach. + + if (storageLink is not null && s3SecretName is not null) + { + sb.AppendLine(" plugins:"); + sb.AppendLine(" - name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" parameters:"); + sb.AppendLine($" barmanObjectName: {cnpg.Name}-object-store"); + } + + return sb.ToString(); + } + + /// + /// Builds the ObjectStore CRD manifest for the Barman Cloud Plugin. + /// This replaces the deprecated inline spec.backup.barmanObjectStore config. + /// The ObjectStore references the cluster and defines S3 destination/credentials. + /// + private static string BuildObjectStoreManifest( + string clusterName, string ns, StorageLink storageLink, string s3SecretName) + { + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: barmancloud.cnpg.io/v1"); + sb.AppendLine("kind: ObjectStore"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {clusterName}-object-store"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("spec:"); + sb.AppendLine(" configuration:"); + sb.AppendLine($" destinationPath: s3://{storageLink.BucketName}/{clusterName}/"); + sb.AppendLine($" endpointURL: {storageLink.Endpoint}"); + sb.AppendLine(" s3Credentials:"); + sb.AppendLine(" accessKeyId:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: ACCESS_KEY"); + sb.AppendLine(" secretAccessKey:"); + sb.AppendLine($" name: {s3SecretName}"); + sb.AppendLine(" key: SECRET_KEY"); + sb.AppendLine(" wal:"); + sb.AppendLine(" compression: gzip"); + sb.AppendLine(" clusterRef:"); + sb.AppendLine($" name: {clusterName}"); + + return sb.ToString(); + } + + /// + /// Builds a ScheduledBackup CRD manifest for automated periodic backups. + /// Uses the pluginBarmanCloud method which works with the Barman Cloud Plugin. + /// + private static string BuildScheduledBackupManifest(string clusterName, string ns, string schedule) + { + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: postgresql.cnpg.io/v1"); + sb.AppendLine("kind: ScheduledBackup"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {clusterName}-scheduled"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("spec:"); + sb.AppendLine($" schedule: \"{schedule}\""); + sb.AppendLine(" backupOwnerReference: self"); + sb.AppendLine(" method: plugin"); + sb.AppendLine(" pluginConfiguration:"); + sb.AppendLine(" name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" cluster:"); + sb.AppendLine($" name: {clusterName}"); + + return sb.ToString(); + } + + /// + /// Builds a Backup CRD manifest for an on-demand backup via the Barman Cloud Plugin. + /// + private static string BuildBackupManifest(string backupName, string clusterName, string ns) + { + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: postgresql.cnpg.io/v1"); + sb.AppendLine("kind: Backup"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {backupName}"); + sb.AppendLine($" namespace: {ns}"); + sb.AppendLine("spec:"); + sb.AppendLine(" method: plugin"); + sb.AppendLine(" pluginConfiguration:"); + sb.AppendLine(" name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" cluster:"); + sb.AppendLine($" name: {clusterName}"); + + return sb.ToString(); + } + + /// + /// Builds a CNPG Cluster manifest that bootstraps from a Barman Cloud Plugin + /// backup with point-in-time recovery to the specified target time. + /// Uses the plugin-based recovery via an external ObjectStore reference. + /// + private static string BuildRestoreManifest( + CnpgCluster restored, CnpgCluster source, DateTime targetTime, string s3SecretName) + { + StorageLink storageLink = source.StorageLink!; + StringBuilder sb = new(); + + sb.AppendLine("apiVersion: postgresql.cnpg.io/v1"); + sb.AppendLine("kind: Cluster"); + sb.AppendLine("metadata:"); + sb.AppendLine($" name: {restored.Name}"); + sb.AppendLine($" namespace: {restored.Namespace}"); + sb.AppendLine("spec:"); + sb.AppendLine($" instances: {restored.Instances}"); + sb.AppendLine($" imageName: ghcr.io/cloudnative-pg/postgresql:{restored.PostgresVersion}"); + sb.AppendLine(" storage:"); + sb.AppendLine($" size: {restored.StorageSize}"); + sb.AppendLine(" plugins:"); + sb.AppendLine(" - name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" parameters:"); + sb.AppendLine($" barmanObjectName: {restored.Name}-object-store"); + sb.AppendLine(" bootstrap:"); + sb.AppendLine(" recovery:"); + sb.AppendLine($" source: {source.Name}"); + sb.AppendLine(" recoveryTarget:"); + sb.AppendLine($" targetTime: \"{targetTime:yyyy-MM-ddTHH:mm:ssZ}\""); + sb.AppendLine(" externalClusters:"); + sb.AppendLine($" - name: {source.Name}"); + sb.AppendLine(" plugin:"); + sb.AppendLine(" name: barman-cloud.cloudnative-pg.io"); + sb.AppendLine(" parameters:"); + sb.AppendLine($" barmanObjectName: {source.Name}-object-store"); + + return sb.ToString(); + } + + // ──────── JSON Parsing ──────── + + /// + /// Parses the CNPG Cluster CRD JSON to extract phase, primary, timeline info. + /// + private static void ParseClusterStatus(string json, CnpgClusterDetail detail) + { + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); + System.Text.Json.JsonElement root = doc.RootElement; + + if (root.TryGetProperty("status", out System.Text.Json.JsonElement status)) + { + if (status.TryGetProperty("phase", out System.Text.Json.JsonElement phase)) + { + detail.Phase = phase.GetString() ?? "Unknown"; + } + + if (status.TryGetProperty("currentPrimary", out System.Text.Json.JsonElement primary)) + { + detail.CurrentPrimary = primary.GetString(); + } + + if (status.TryGetProperty("readyInstances", out System.Text.Json.JsonElement ready)) + { + detail.ReadyInstances = ready.GetInt32(); + } + + if (status.TryGetProperty("currentPrimaryTimestamp", out _) + && status.TryGetProperty("timelineID", out System.Text.Json.JsonElement timeline)) + { + detail.CurrentTimeline = timeline.GetInt32(); + } + } + } + + /// + /// Parses a kubectl "get pods -o json" response into a list of CnpgPodInfo. + /// Determines role (primary vs replica) by comparing pod name to current primary. + /// + private static List ParsePodList(string json, string? currentPrimary) + { + List pods = []; + using System.Text.Json.JsonDocument doc = System.Text.Json.JsonDocument.Parse(json); + System.Text.Json.JsonElement root = doc.RootElement; + + if (!root.TryGetProperty("items", out System.Text.Json.JsonElement items)) + { + return pods; + } + + foreach (System.Text.Json.JsonElement item in items.EnumerateArray()) + { + string podName = item.GetProperty("metadata").GetProperty("name").GetString() ?? ""; + string podStatus = "Unknown"; + bool ready = false; + string? node = null; + DateTime? startTime = null; + int restarts = 0; + + if (item.TryGetProperty("spec", out System.Text.Json.JsonElement spec) + && spec.TryGetProperty("nodeName", out System.Text.Json.JsonElement nodeName)) + { + node = nodeName.GetString(); + } + + if (item.TryGetProperty("status", out System.Text.Json.JsonElement statusEl)) + { + if (statusEl.TryGetProperty("phase", out System.Text.Json.JsonElement phaseEl)) + { + podStatus = phaseEl.GetString() ?? "Unknown"; + } + + if (statusEl.TryGetProperty("startTime", out System.Text.Json.JsonElement startEl)) + { + if (DateTime.TryParse(startEl.GetString(), out DateTime parsed)) + { + startTime = parsed; + } + } + + // Sum up restart counts from all containers. + + if (statusEl.TryGetProperty("containerStatuses", out System.Text.Json.JsonElement containers)) + { + foreach (System.Text.Json.JsonElement container in containers.EnumerateArray()) + { + if (container.TryGetProperty("restartCount", out System.Text.Json.JsonElement rc)) + { + restarts += rc.GetInt32(); + } + + if (container.TryGetProperty("ready", out System.Text.Json.JsonElement readyEl)) + { + ready = ready || readyEl.GetBoolean(); + } + } + } + } + + // Determine role from CNPG labels or by matching against known primary. + + string role = "replica"; + + if (item.TryGetProperty("metadata", out System.Text.Json.JsonElement metadata) + && metadata.TryGetProperty("labels", out System.Text.Json.JsonElement labels) + && labels.TryGetProperty("cnpg.io/instanceRole", out System.Text.Json.JsonElement roleLabel)) + { + role = roleLabel.GetString() ?? "replica"; + } + else if (podName == currentPrimary) + { + role = "primary"; + } + + pods.Add(new CnpgPodInfo + { + Name = podName, + Role = role, + Status = podStatus, + Ready = ready, + Node = node, + StartTime = startTime, + Restarts = restarts + }); + } + + // Sort: primary first, then replicas by name. + + return [.. pods.OrderBy(p => p.Role == "primary" ? 0 : 1).ThenBy(p => p.Name)]; + } +} diff --git a/src/EntKube.Web/Services/ComponentCatalog.cs b/src/EntKube.Web/Services/ComponentCatalog.cs new file mode 100644 index 0000000..7b51375 --- /dev/null +++ b/src/EntKube.Web/Services/ComponentCatalog.cs @@ -0,0 +1,1012 @@ +namespace EntKube.Web.Services; + +/// +/// A catalog entry describes a well-known component that EntKube can manage. +/// These are pre-configured with Helm chart details so operators only need +/// to choose which component to install and optionally tweak the values — +/// they never need to hunt for repo URLs, chart names, or recommended versions. +/// +/// Think of this as a curated app store for Kubernetes infrastructure. +/// +public class CatalogEntry +{ + /// Unique key for this catalog entry (e.g. "kube-prometheus-stack"). + public required string Key { get; init; } + + /// Human-friendly display name (e.g. "Kube Prometheus Stack"). + public required string DisplayName { get; init; } + + /// Short description of what this component does. + public required string Description { get; init; } + + /// Bootstrap icon class for the UI (e.g. "bi-graph-up"). + public required string Icon { get; init; } + + /// Category for grouping in the UI (e.g. "Monitoring", "Storage"). + public required string Category { get; init; } + + /// Component type to store on ClusterComponent. + public string ComponentType { get; init; } = "HelmChart"; + + /// Helm repository URL where the chart lives. + public required string HelmRepoUrl { get; init; } + + /// Chart name within the repo. + public required string HelmChartName { get; init; } + + /// Recommended/tested version. Null means latest. + public string? HelmChartVersion { get; init; } + + /// Default namespace where this should be deployed. + public required string DefaultNamespace { get; init; } + + /// Default release name for Helm. + public string? DefaultReleaseName { get; init; } + + /// Default Helm values YAML that provides a sensible starting config. + public string? DefaultValues { get; init; } + + /// + /// Components that MUST be present on the cluster before this one can be installed. + /// References other catalog entry keys. If any dependency is missing, the UI + /// will warn the operator and block install until they're satisfied. + /// + public IReadOnlyList Dependencies { get; init; } = []; + + /// + /// Components that can satisfy a "group" dependency. For example, a component + /// may depend on "ingress" which can be satisfied by either "traefik" or "istio". + /// Keys here reference DependencyGroup names, not catalog entry keys. + /// + public IReadOnlyList RequiresOneOf { get; init; } = []; + + /// + /// Companion components that should be auto-installed alongside this one. + /// When this component installs successfully, any companions that don't already + /// exist on the cluster will be registered and installed automatically. + /// Unlike Dependencies (which block install if missing), companions are additive. + /// + public IReadOnlyList CompanionKeys { get; init; } = []; + + /// + /// Form fields that provide a user-friendly way to configure the most common + /// Helm values. These render as simple form controls (text boxes, selects, + /// toggles) so operators don't need to understand YAML for routine settings. + /// An "Advanced" accordion always remains available for full YAML editing. + /// + public IReadOnlyList FormFields { get; init; } = []; +} + +/// +/// A dependency requirement where any one of several components can satisfy it. +/// For example, "ingress" can be satisfied by either Traefik or Istio. +/// +public class DependencyRequirement +{ + /// Human-readable name for this requirement (e.g. "Ingress Controller"). + public required string Label { get; init; } + + /// Catalog entry keys that can satisfy this requirement. + public required IReadOnlyList Options { get; init; } +} + +/// +/// The component catalog is a static, in-memory registry of all the infrastructure +/// components that EntKube knows how to manage out of the box. When an operator +/// wants to install something on a cluster, they pick from this catalog — the +/// Helm details are already filled in. They just configure the values if needed. +/// +/// Components can declare dependencies on other components. The UI will show +/// which dependencies are satisfied and which are missing before allowing install. +/// Some dependencies are "one-of" — e.g. you need an ingress controller, but +/// it can be either Traefik or Istio. +/// +/// Adding a new component to the platform is as simple as adding a new entry here. +/// +public static class ComponentCatalog +{ + /// + /// All available catalog entries. Order here determines display order in the UI. + /// + public static IReadOnlyList Entries { get; } = + [ + // ── Ingress ── + + new CatalogEntry + { + Key = "traefik", + DisplayName = "Traefik (Gateway API)", + Description = "Cloud-native ingress controller with Gateway API support. Handles TLS termination, routing, and load balancing using the standard Kubernetes Gateway API.", + Icon = "bi-signpost-split", + Category = "Ingress", + HelmRepoUrl = "https://traefik.github.io/charts", + HelmChartName = "traefik", + DefaultNamespace = "traefik", + DefaultReleaseName = "traefik", + FormFields = + [ + new ComponentFormField + { + Key = "gateway-name", Label = "Gateway Name", + YamlPath = "gateway.name", Type = FormFieldType.Text, + DefaultValue = "traefik-gateway", + HelpText = "Name of the Gateway resource Traefik manages" + }, + new ComponentFormField + { + Key = "http-port", Label = "HTTP Port", + YamlPath = "ports.web.exposedPort", Type = FormFieldType.Number, + DefaultValue = "80" + }, + new ComponentFormField + { + Key = "https-port", Label = "HTTPS Port", + YamlPath = "ports.websecure.exposedPort", Type = FormFieldType.Number, + DefaultValue = "443" + }, + new ComponentFormField + { + Key = "gateway-api-crds", Label = "Install Gateway API CRDs", + YamlPath = "gatewayAPI.enabled", Type = FormFieldType.Toggle, + DefaultValue = "true", + HelpText = "Install the Gateway API CRD definitions with this chart" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "100m", Placeholder = "e.g. 100m, 250m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "128Mi", Placeholder = "e.g. 128Mi, 256Mi" + } + ], + DefaultValues = """ + # Enable Gateway API provider (standard Kubernetes Gateway API) + providers: + kubernetesGateway: + enabled: true + + # Gateway resource — Traefik will create and manage this + gateway: + enabled: true + name: traefik-gateway + listeners: + - name: web + protocol: HTTP + port: 80 + - name: websecure + protocol: HTTPS + port: 443 + tls: + mode: Terminate + certificateRefs: + - name: wildcard-tls + + # Entrypoints + ports: + web: + port: 8000 + exposedPort: 80 + websecure: + port: 8443 + exposedPort: 443 + tls: + enabled: true + + # Resource allocation + resources: + requests: + memory: 128Mi + cpu: 100m + + # Install Gateway API CRDs + gatewayAPI: + enabled: true + """ + }, + + new CatalogEntry + { + Key = "istio", + DisplayName = "Istio Gateway (External)", + Description = "Internet-facing ingress gateway with Gateway API support. Provides a public LoadBalancer for external traffic entering the cluster.", + Icon = "bi-diagram-3", + Category = "Ingress", + HelmRepoUrl = "https://istio-release.storage.googleapis.com/charts", + HelmChartName = "gateway", + DefaultNamespace = "istio-system", + DefaultReleaseName = "istio-ingress-external", + Dependencies = ["istio-base"], + FormFields = + [ + new ComponentFormField + { + Key = "service-type", Label = "Service Type", + YamlPath = "service.type", Type = FormFieldType.Select, + DefaultValue = "LoadBalancer", + Options = ["LoadBalancer", "NodePort", "ClusterIP"], + HelpText = "How the external gateway is exposed" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "100m", Placeholder = "e.g. 100m, 250m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "128Mi", Placeholder = "e.g. 128Mi, 256Mi" + } + ], + DefaultValues = """ + # Istio External Gateway — internet-facing LoadBalancer + # Requires istio-base (istiod) to be installed first + + service: + type: LoadBalancer + + # Resource allocation + resources: + requests: + memory: 128Mi + cpu: 100m + """ + }, + + new CatalogEntry + { + Key = "istio-internal", + DisplayName = "Istio Gateway (Internal)", + Description = "Internal ingress gateway for private/mesh-internal traffic. Provides a private LoadBalancer only reachable from within the network (VPC/VNET).", + Icon = "bi-diagram-3", + Category = "Ingress", + HelmRepoUrl = "https://istio-release.storage.googleapis.com/charts", + HelmChartName = "gateway", + DefaultNamespace = "istio-system", + DefaultReleaseName = "istio-ingress-internal", + Dependencies = ["istio-base"], + FormFields = + [ + new ComponentFormField + { + Key = "service-type", Label = "Service Type", + YamlPath = "service.type", Type = FormFieldType.Select, + DefaultValue = "LoadBalancer", + Options = ["LoadBalancer", "NodePort", "ClusterIP"], + HelpText = "How the internal gateway is exposed" + }, + new ComponentFormField + { + Key = "internal-annotation", Label = "Internal LB Annotation", + YamlPath = "service.annotations.networking\\.istio\\.io/internal", Type = FormFieldType.Text, + DefaultValue = "true", + HelpText = "Cloud provider annotation to make the LB internal (override per provider)" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "100m", Placeholder = "e.g. 100m, 250m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "128Mi", Placeholder = "e.g. 128Mi, 256Mi" + } + ], + DefaultValues = """ + # Istio Internal Gateway — private LoadBalancer (not internet-facing) + # Requires istio-base (istiod) to be installed first + + service: + type: LoadBalancer + annotations: + # Generic internal annotation — override with your cloud provider's annotation: + # Azure: service.beta.kubernetes.io/azure-load-balancer-internal: "true" + # GCP: cloud.google.com/load-balancer-type: "Internal" + # AWS: service.beta.kubernetes.io/aws-load-balancer-scheme: "internal" + networking.istio.io/internal: "true" + + # Resource allocation + resources: + requests: + memory: 128Mi + cpu: 100m + """ + }, + + new CatalogEntry + { + Key = "istio-base", + DisplayName = "Istio Base (istiod)", + Description = "Istio control plane (istiod). Required foundation for Istio service mesh — provides the control plane that manages proxies, certificates, and configuration.", + Icon = "bi-diagram-3", + Category = "Ingress", + HelmRepoUrl = "https://istio-release.storage.googleapis.com/charts", + HelmChartName = "istiod", + DefaultNamespace = "istio-system", + DefaultReleaseName = "istiod", + FormFields = + [ + new ComponentFormField + { + Key = "gateway-api", Label = "Enable Gateway API", + YamlPath = "pilot.env.PILOT_ENABLE_GATEWAY_API", Type = FormFieldType.Toggle, + DefaultValue = "true", + HelpText = "Enable Gateway API support in the Istio control plane" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "100m", Placeholder = "e.g. 100m, 250m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "256Mi", Placeholder = "e.g. 256Mi, 512Mi" + } + ], + DefaultValues = """ + # Enable Gateway API support in istiod + pilot: + env: + PILOT_ENABLE_GATEWAY_API: "true" + PILOT_ENABLE_GATEWAY_API_STATUS: "true" + PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER: "true" + + # Resource allocation + resources: + requests: + memory: 256Mi + cpu: 100m + """ + }, + + // ── Certificate Management ── + + new CatalogEntry + { + Key = "cert-manager", + DisplayName = "cert-manager", + Description = "Automatic TLS certificate management for Kubernetes. Issues and renews certificates from Let's Encrypt and other CAs. Required for HTTPS on exposed services.", + Icon = "bi-file-earmark-lock", + Category = "Certificate Management", + HelmRepoUrl = "https://charts.jetstack.io", + HelmChartName = "cert-manager", + DefaultNamespace = "cert-manager", + DefaultReleaseName = "cert-manager", + FormFields = + [ + new ComponentFormField + { + Key = "install-crds", Label = "Install CRDs", + YamlPath = "crds.enabled", Type = FormFieldType.Toggle, + DefaultValue = "true", + HelpText = "Install cert-manager Custom Resource Definitions" + }, + new ComponentFormField + { + Key = "gateway-api", Label = "Gateway API Integration", + YamlPath = "featureGates", Type = FormFieldType.Text, + DefaultValue = "ExperimentalGatewayAPISupport=true", + HelpText = "Feature gates to enable (Gateway API support recommended)" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "50m", Placeholder = "e.g. 50m, 100m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "128Mi", Placeholder = "e.g. 128Mi, 256Mi" + } + ], + DefaultValues = """ + # Install CRDs with the chart + crds: + enabled: true + + # Enable Gateway API integration + # cert-manager will watch Gateway resources for TLS config + featureGates: ExperimentalGatewayAPISupport=true + + # Resource allocation + resources: + requests: + memory: 128Mi + cpu: 50m + """ + }, + + new CatalogEntry + { + Key = "letsencrypt-issuer", + DisplayName = "Let's Encrypt ClusterIssuer", + Description = "Configures a ClusterIssuer that uses Let's Encrypt to automatically issue and renew TLS certificates. Works with both HTTP-01 and DNS-01 challenge types.", + Icon = "bi-lock", + Category = "Certificate Management", + HelmRepoUrl = "https://charts.jetstack.io", + HelmChartName = "cert-manager", + DefaultNamespace = "cert-manager", + DefaultReleaseName = "letsencrypt-issuer", + Dependencies = ["cert-manager"], + ComponentType = "Manifest", + FormFields = + [ + new ComponentFormField + { + Key = "acme-email", Label = "ACME Email", + YamlPath = "spec.acme.email", Type = FormFieldType.Text, + DefaultValue = "ops@example.com", + Placeholder = "ops@yourdomain.com", + HelpText = "Contact email for Let's Encrypt notifications" + }, + new ComponentFormField + { + Key = "acme-server", Label = "ACME Server", + YamlPath = "spec.acme.server", Type = FormFieldType.Select, + DefaultValue = "https://acme-v02.api.letsencrypt.org/directory", + Options = ["https://acme-v02.api.letsencrypt.org/directory", "https://acme-staging-v02.api.letsencrypt.org/directory"], + HelpText = "Use staging for testing, production for real certificates" + } + ], + DefaultValues = """ + # This component applies a ClusterIssuer manifest via Helm. + # Change the email to your operations contact. + + # Let's Encrypt Production + apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + metadata: + name: letsencrypt-prod + spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: ops@example.com + privateKeySecretRef: + name: letsencrypt-prod-key + solvers: + - http01: + gatewayHTTPRoute: + parentRefs: + - name: traefik-gateway + namespace: traefik + """ + }, + + // ── Monitoring ── + + new CatalogEntry + { + Key = "kube-prometheus-stack", + DisplayName = "Kube Prometheus Stack", + Description = "Full monitoring stack with Prometheus, Grafana, and Alertmanager. Provides metrics collection, dashboards, and alerting for your cluster. Can be exposed externally via Gateway API.", + Icon = "bi-graph-up", + Category = "Monitoring", + HelmRepoUrl = "https://prometheus-community.github.io/helm-charts", + HelmChartName = "kube-prometheus-stack", + DefaultNamespace = "monitoring", + DefaultReleaseName = "kube-prometheus-stack", + RequiresOneOf = + [ + new DependencyRequirement + { + Label = "Ingress Controller", + Options = ["traefik", "istio"] + } + ], + Dependencies = ["cert-manager", "letsencrypt-issuer"], + FormFields = + [ + new ComponentFormField + { + Key = "grafana-password", Label = "Grafana Admin Password", + YamlPath = "grafana.adminPassword", Type = FormFieldType.Password, + DefaultValue = "admin", + HelpText = "Password for the Grafana 'admin' user — stored encrypted in the vault", + StoreAsSecret = true, + SecretName = "GRAFANA_ADMIN_PASSWORD" + }, + new ComponentFormField + { + Key = "grafana-enabled", Label = "Enable Grafana", + YamlPath = "grafana.enabled", Type = FormFieldType.Toggle, + DefaultValue = "true" + }, + new ComponentFormField + { + Key = "alertmanager-enabled", Label = "Enable Alertmanager", + YamlPath = "alertmanager.enabled", Type = FormFieldType.Toggle, + DefaultValue = "true" + }, + new ComponentFormField + { + Key = "retention", Label = "Prometheus Retention", + YamlPath = "prometheus.prometheusSpec.retention", Type = FormFieldType.Text, + DefaultValue = "15d", Placeholder = "e.g. 15d, 30d, 90d", + HelpText = "How long to keep metrics data" + }, + new ComponentFormField + { + Key = "prom-cpu-request", Label = "Prometheus CPU Request", + YamlPath = "prometheus.prometheusSpec.resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "250m", Placeholder = "e.g. 250m, 500m" + }, + new ComponentFormField + { + Key = "prom-memory-request", Label = "Prometheus Memory Request", + YamlPath = "prometheus.prometheusSpec.resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "512Mi", Placeholder = "e.g. 512Mi, 1Gi" + } + ], + DefaultValues = """ + # Grafana configuration + grafana: + enabled: true + adminPassword: admin + + # Alertmanager configuration + alertmanager: + enabled: true + + # Prometheus configuration + prometheus: + prometheusSpec: + retention: 15d + resources: + requests: + memory: 512Mi + cpu: 250m + + # ── External access via Gateway API HTTPRoutes ── + # Uncomment and adjust the hostnames to expose services externally. + # Requires an ingress controller (Traefik or Istio) and cert-manager. + + # grafana: + # ingress: + # enabled: false # We use HTTPRoute instead + # --- + # To expose Grafana, apply an HTTPRoute like: + # apiVersion: gateway.networking.k8s.io/v1 + # kind: HTTPRoute + # metadata: + # name: grafana + # namespace: monitoring + # annotations: + # cert-manager.io/cluster-issuer: letsencrypt-prod + # spec: + # parentRefs: + # - name: traefik-gateway + # namespace: traefik + # hostnames: + # - grafana.example.com + # rules: + # - backendRefs: + # - name: kube-prometheus-stack-grafana + # port: 80 + """ + }, + + // ── Storage ── + + new CatalogEntry + { + Key = "minio", + DisplayName = "MinIO", + Description = "High-performance S3-compatible object storage. Use for application data, backups, and artifact storage.", + Icon = "bi-bucket", + Category = "Storage", + HelmRepoUrl = "https://charts.min.io/", + HelmChartName = "minio", + DefaultNamespace = "minio", + DefaultReleaseName = "minio", + FormFields = + [ + new ComponentFormField + { + Key = "root-user", Label = "Root User", + YamlPath = "rootUser", Type = FormFieldType.Text, + DefaultValue = "admin", + HelpText = "Admin username for the MinIO console" + }, + new ComponentFormField + { + Key = "root-password", Label = "Root Password", + YamlPath = "rootPassword", Type = FormFieldType.Password, + DefaultValue = "changeme123", + HelpText = "Admin password — change this before production use" + }, + new ComponentFormField + { + Key = "mode", Label = "Deployment Mode", + YamlPath = "mode", Type = FormFieldType.Select, + DefaultValue = "standalone", + Options = ["standalone", "distributed"], + HelpText = "Standalone for dev/small, distributed for HA" + }, + new ComponentFormField + { + Key = "storage-size", Label = "Storage Size", + YamlPath = "persistence.size", Type = FormFieldType.Text, + DefaultValue = "50Gi", Placeholder = "e.g. 50Gi, 100Gi, 500Gi", + HelpText = "Persistent volume size for object storage" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "250m", Placeholder = "e.g. 250m, 500m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "512Mi", Placeholder = "e.g. 512Mi, 1Gi" + } + ], + DefaultValues = """ + # MinIO root credentials + rootUser: admin + rootPassword: changeme123 + + # Storage configuration + mode: standalone + persistence: + enabled: true + size: 50Gi + + # Resource allocation + resources: + requests: + memory: 512Mi + cpu: 250m + """ + }, + + // ── Databases ── + + new CatalogEntry + { + Key = "cloudnative-pg", + DisplayName = "CloudNativePG", + Description = "Kubernetes operator for managing PostgreSQL clusters. Handles provisioning, high availability, backups, and automated failover.", + Icon = "bi-database", + Category = "Databases", + HelmRepoUrl = "https://cloudnative-pg.github.io/charts", + HelmChartName = "cloudnative-pg", + DefaultNamespace = "cnpg-system", + DefaultReleaseName = "cnpg", + CompanionKeys = ["barman-cloud-plugin"], + FormFields = + [ + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "100m", Placeholder = "e.g. 100m, 250m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "256Mi", Placeholder = "e.g. 256Mi, 512Mi" + } + ], + DefaultValues = """ + # Operator configuration + resources: + requests: + memory: 256Mi + cpu: 100m + """ + }, + + new CatalogEntry + { + Key = "barman-cloud-plugin", + DisplayName = "Barman Cloud Plugin", + Description = "Backup and recovery plugin for CloudNativePG. Provides S3-compatible backup via Barman Cloud, WAL archiving, and point-in-time recovery. Required for CNPG backup/restore operations.", + Icon = "bi-cloud-arrow-up", + Category = "Databases", + HelmRepoUrl = "https://cloudnative-pg.github.io/charts", + HelmChartName = "barman-cloud", + DefaultNamespace = "cnpg-system", + DefaultReleaseName = "barman-cloud", + Dependencies = ["cloudnative-pg"], + DefaultValues = """ + # Barman Cloud Plugin configuration + # Installed as companion to CloudNativePG operator + """ + }, + + new CatalogEntry + { + Key = "mongodb-operator", + DisplayName = "MongoDB Community Operator", + Description = "Kubernetes operator for deploying and managing MongoDB replica sets. Handles provisioning, scaling, upgrades, and TLS configuration.", + Icon = "bi-database-gear", + Category = "Databases", + HelmRepoUrl = "https://mongodb.github.io/helm-charts", + HelmChartName = "community-operator", + DefaultNamespace = "mongodb-system", + DefaultReleaseName = "mongodb-operator", + FormFields = + [ + new ComponentFormField + { + Key = "watch-namespace", Label = "Watch Namespace", + YamlPath = "operator.watchNamespace", Type = FormFieldType.Text, + DefaultValue = "*", Placeholder = "* for all, or specific namespace", + HelpText = "Which namespaces the operator watches for MongoDBCommunity resources" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "operator.resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "100m", Placeholder = "e.g. 100m, 250m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "operator.resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "256Mi", Placeholder = "e.g. 256Mi, 512Mi" + } + ], + DefaultValues = """ + # MongoDB Community Operator configuration + operator: + watchNamespace: "*" + resources: + requests: + memory: 256Mi + cpu: 100m + """ + }, + + // ── Identity ── + + new CatalogEntry + { + Key = "keycloak", + DisplayName = "Keycloak", + Description = "Open-source identity and access management. Provides SSO, user federation, social login, and fine-grained authorization. Can be exposed externally via Gateway API.", + Icon = "bi-shield-lock", + Category = "Identity", + HelmRepoUrl = "https://codecentric.github.io/helm-charts", + HelmChartName = "keycloakx", + DefaultNamespace = "keycloak", + DefaultReleaseName = "keycloak", + RequiresOneOf = + [ + new DependencyRequirement + { + Label = "Ingress Controller", + Options = ["traefik", "istio"] + } + ], + Dependencies = ["cert-manager", "letsencrypt-issuer", "cloudnative-pg"], + FormFields = + [ + new ComponentFormField + { + Key = "http-path", Label = "HTTP Relative Path", + YamlPath = "http.relativePath", Type = FormFieldType.Text, + DefaultValue = "/auth", + HelpText = "Base path where Keycloak is served" + }, + new ComponentFormField + { + Key = "db-vendor", Label = "Database Vendor", + YamlPath = "database.vendor", Type = FormFieldType.Select, + DefaultValue = "postgres", + Options = ["postgres", "mariadb", "mysql"], + HelpText = "Database backend for Keycloak" + }, + new ComponentFormField + { + Key = "cpu-request", Label = "CPU Request", + YamlPath = "resources.requests.cpu", Type = FormFieldType.Text, + DefaultValue = "250m", Placeholder = "e.g. 250m, 500m" + }, + new ComponentFormField + { + Key = "memory-request", Label = "Memory Request", + YamlPath = "resources.requests.memory", Type = FormFieldType.Text, + DefaultValue = "512Mi", Placeholder = "e.g. 512Mi, 1Gi" + } + ], + DefaultValues = """ + # Database configuration (requires CloudNativePG) + database: + vendor: postgres + + # HTTP configuration + http: + relativePath: /auth + + # Resource allocation + resources: + requests: + memory: 512Mi + cpu: 250m + """ + } + ]; + + /// + /// Looks up a catalog entry by its key. Returns null if not found. + /// + public static CatalogEntry? GetByKey(string key) + { + return Entries.FirstOrDefault(e => string.Equals(e.Key, key, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Finds a catalog entry that matches a discovered Helm release by checking + /// the release name against the catalog Key, DefaultReleaseName, and HelmChartName. + /// + public static CatalogEntry? FindByRelease(string releaseName, string? chartName) + { + return Entries.FirstOrDefault(e => + string.Equals(e.Key, releaseName, StringComparison.OrdinalIgnoreCase) + || string.Equals(e.DefaultReleaseName, releaseName, StringComparison.OrdinalIgnoreCase) + || string.Equals(e.HelmChartName, releaseName, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(chartName) && string.Equals(e.HelmChartName, chartName, StringComparison.OrdinalIgnoreCase))); + } + + /// + /// Checks whether a component (by name and optional chart/release info) matches + /// a given catalog entry. This handles cases where the Helm release name on + /// the cluster differs from the catalog's DefaultReleaseName — e.g. a release + /// named "istio-ingress" matching catalog entry with DefaultReleaseName "istio-ingress-external". + /// + public static bool IsComponentMatch(string componentName, CatalogEntry entry, + string? helmChartName = null, string? releaseName = null) + { + // Direct matches on Key, DefaultReleaseName, or HelmChartName. + + if (string.Equals(componentName, entry.Key, StringComparison.OrdinalIgnoreCase) + || string.Equals(componentName, entry.DefaultReleaseName, StringComparison.OrdinalIgnoreCase) + || string.Equals(componentName, entry.HelmChartName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Match by the component's Helm chart name (set during import from catalog enrichment). + // For example, component with HelmChartName "gateway" matches catalog entry with HelmChartName "gateway". + + if (!string.IsNullOrEmpty(helmChartName) + && string.Equals(helmChartName, entry.HelmChartName, StringComparison.OrdinalIgnoreCase)) + { + // HelmChartName alone can be ambiguous (e.g. both istio-external and istio-internal use "gateway"). + // Also check that the component's HelmRepoUrl matches to disambiguate. + // But for now, if the chart name matches, treat it as installed. + return true; + } + + // Match by the component's ReleaseName if stored separately from Name. + + if (!string.IsNullOrEmpty(releaseName) + && (string.Equals(releaseName, entry.Key, StringComparison.OrdinalIgnoreCase) + || string.Equals(releaseName, entry.DefaultReleaseName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + } + + /// + /// Resolves a list of component names (with optional chart names) to their catalog keys. + /// For example, if "cnpg" is installed, this returns "cloudnative-pg" (the catalog key). + /// Components that don't match any catalog entry keep their original name. + /// This ensures dependency checks work correctly for imported components. + /// + public static List ResolveInstalledKeys(IEnumerable<(string Name, string? HelmChartName, string? ReleaseName)> components) + { + List resolved = []; + + foreach ((string name, string? chartName, string? releaseName) in components) + { + CatalogEntry? match = Entries.FirstOrDefault(e => IsComponentMatch(name, e, chartName, releaseName)); + resolved.Add(match?.Key ?? name); + } + + return resolved; + } + + /// + /// Returns catalog entries grouped by category, in the order they appear. + /// + public static IReadOnlyList> GetByCategory() + { + return Entries.GroupBy(e => e.Category).ToList(); + } + + /// + /// Checks which dependencies are satisfied and which are missing for a given + /// catalog entry, based on the components already present on the cluster. + /// Returns a result describing the status of each dependency. + /// + public static DependencyCheckResult CheckDependencies(CatalogEntry entry, IEnumerable installedComponentNames) + { + HashSet installed = new(installedComponentNames, StringComparer.OrdinalIgnoreCase); + List missingDirect = []; + List missingOneOf = []; + + // Check direct dependencies — all must be present. + + foreach (string dep in entry.Dependencies) + { + if (!installed.Contains(dep)) + { + missingDirect.Add(dep); + } + } + + // Check one-of dependencies — at least one option must be present. + + foreach (DependencyRequirement req in entry.RequiresOneOf) + { + bool satisfied = req.Options.Any(opt => installed.Contains(opt)); + if (!satisfied) + { + missingOneOf.Add(req); + } + } + + return new DependencyCheckResult + { + IsSatisfied = missingDirect.Count == 0 && missingOneOf.Count == 0, + MissingDependencies = missingDirect, + MissingOneOfRequirements = missingOneOf + }; + } + + /// + /// Creates a ComponentRegistration from a catalog entry, pre-filling all + /// the Helm details. The operator can then adjust the values before installing. + /// + public static ComponentRegistration ToRegistration(CatalogEntry entry) + { + return new ComponentRegistration + { + Name = entry.Key, + ComponentType = entry.ComponentType, + Namespace = entry.DefaultNamespace, + HelmRepoUrl = entry.HelmRepoUrl, + HelmChartName = entry.HelmChartName, + HelmChartVersion = entry.HelmChartVersion, + ReleaseName = entry.DefaultReleaseName ?? entry.Key, + HelmValues = entry.DefaultValues + }; + } +} + +/// +/// Result of checking whether a catalog entry's dependencies are met +/// given the set of currently installed components on a cluster. +/// +public class DependencyCheckResult +{ + /// True if all dependencies are satisfied. + public bool IsSatisfied { get; init; } + + /// Direct dependency keys that are not yet present. + public IReadOnlyList MissingDependencies { get; init; } = []; + + /// One-of requirements where none of the options are present. + public IReadOnlyList MissingOneOfRequirements { get; init; } = []; +} diff --git a/src/EntKube.Web/Services/ComponentLifecycleService.cs b/src/EntKube.Web/Services/ComponentLifecycleService.cs new file mode 100644 index 0000000..9c7ed99 --- /dev/null +++ b/src/EntKube.Web/Services/ComponentLifecycleService.cs @@ -0,0 +1,1130 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using EntKube.Web.Data; +using k8s; +using k8s.Models; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Input model for registering a new component on a cluster. +/// Captures all the Helm chart details needed for lifecycle management. +/// +public class ComponentRegistration +{ + public required string Name { get; set; } + public required string ComponentType { get; set; } + public string? Namespace { get; set; } + public string? HelmRepoUrl { get; set; } + public string? HelmChartName { get; set; } + public string? HelmChartVersion { get; set; } + public string? ReleaseName { get; set; } + public string? HelmValues { get; set; } + public string? Configuration { get; set; } +} + +/// +/// Describes a Helm CLI command that can be executed against a cluster. +/// Built by the lifecycle service and executed by the UI or a background worker. +/// This separation keeps the data layer testable without needing the helm binary. +/// +public class HelmCommand +{ + public required string Operation { get; set; } + public required string ReleaseName { get; set; } + public string? ChartReference { get; set; } + public string? Namespace { get; set; } + public string? RepoUrl { get; set; } + public string? Version { get; set; } + public bool HasValues { get; set; } + public string? ValuesYaml { get; set; } +} + +/// +/// Manages the full lifecycle of cluster components — registration, configuration, +/// install preparation, result tracking, and uninstall. The service handles the +/// data/state side of lifecycle management; actual Helm CLI execution is delegated +/// to the caller (UI or background worker) using the HelmCommand objects. +/// +/// Lifecycle flow: +/// 1. RegisterComponentAsync → creates component with NotInstalled status +/// 2. UpdateConfigurationAsync → sets/updates Helm values, version, etc. +/// 3. PrepareInstallAsync → validates and transitions to Installing +/// 4. ExecuteHelmAsync → runs the actual helm command against the cluster +/// 5. MarkInstallResultAsync → records success (Installed) or failure (Failed) +/// 6. PrepareUninstallAsync → transitions to Uninstalling +/// 7. ExecuteHelmAsync → runs helm uninstall +/// 8. MarkUninstallResultAsync → removes or resets the component +/// +public class ComponentLifecycleService(IDbContextFactory dbFactory, VaultService vaultService) +{ + /// + /// Registers a new component on a cluster. The component starts in NotInstalled + /// status — it's just a record of what should be deployed, not yet deployed. + /// Think of this as adding a line item to a deployment plan. + /// + public async Task RegisterComponentAsync( + Guid clusterId, ComponentRegistration registration, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Check that no component with this name already exists on the cluster. + + bool exists = await db.ClusterComponents + .AnyAsync(c => c.ClusterId == clusterId && c.Name == registration.Name, ct); + + if (exists) + { + throw new InvalidOperationException( + $"A component named '{registration.Name}' already exists on this cluster."); + } + + // Create the component with all the Helm details filled in. + // ReleaseName defaults to the component name if not specified — this is + // the name Helm will use for the release on the cluster. + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = clusterId, + Name = registration.Name, + ComponentType = registration.ComponentType, + Namespace = registration.Namespace, + HelmRepoUrl = registration.HelmRepoUrl, + HelmChartName = registration.HelmChartName, + HelmChartVersion = registration.HelmChartVersion, + ReleaseName = registration.ReleaseName ?? registration.Name, + HelmValues = registration.HelmValues, + Configuration = registration.Configuration, + Status = ComponentStatus.NotInstalled + }; + + db.ClusterComponents.Add(component); + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Updates the configuration of an existing component. This can be done + /// before initial install or to prepare an upgrade of an already-installed + /// component. Changes to values or version take effect on the next install/upgrade. + /// + public async Task UpdateConfigurationAsync( + Guid componentId, string? helmValues, string? chartVersion = null, + string? helmRepoUrl = null, string? configuration = null, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + // Update only the fields that were provided. + + if (helmValues is not null) + { + component.HelmValues = helmValues; + } + + if (chartVersion is not null) + { + component.HelmChartVersion = chartVersion; + } + + if (helmRepoUrl is not null) + { + component.HelmRepoUrl = helmRepoUrl; + } + + if (configuration is not null) + { + component.Configuration = configuration; + } + + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Validates that a component is ready to install and transitions it to + /// Installing status. This is the gatekeeper — if the component doesn't + /// have the minimum required info (chart name, namespace), we reject early + /// rather than failing mid-install. + /// + public async Task PrepareInstallAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + // Only NotInstalled or Failed components can be installed. + // If it's already installed, the user should use upgrade instead. + + if (component.Status == ComponentStatus.Installed) + { + throw new InvalidOperationException( + "Component is already installed. Use upgrade to reconfigure."); + } + + if (component.Status is ComponentStatus.Installing or ComponentStatus.Uninstalling) + { + throw new InvalidOperationException( + "Component has an operation in progress. Wait for it to complete."); + } + + // Validate the minimum required fields for a Helm install. + + if (string.IsNullOrWhiteSpace(component.HelmChartName)) + { + throw new InvalidOperationException( + "Helm chart name is required. Configure the component before installing."); + } + + // Transition to Installing — the caller should now execute the Helm command. + + component.Status = ComponentStatus.Installing; + component.LastError = null; + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Records the result of a Helm install/upgrade operation. Called by the + /// UI or worker after executing the Helm command against the cluster. + /// On success, marks as Installed with a timestamp. On failure, marks as + /// Failed with the error message so the user can diagnose and retry. + /// + public async Task MarkInstallResultAsync( + Guid componentId, bool success, string? error = null, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + if (success) + { + component.Status = ComponentStatus.Installed; + component.InstalledAt = DateTime.UtcNow; + component.LastError = null; + } + else + { + component.Status = ComponentStatus.Failed; + component.LastError = error; + } + + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Checks if a successfully installed component has companion charts defined + /// in the catalog. If so, registers and installs each companion that doesn't + /// already exist on the cluster. Called after a successful install/upgrade. + /// + /// For example: installing cloudnative-pg will auto-install barman-cloud-plugin. + /// + public async Task> InstallCompanionsAsync( + Guid componentId, CancellationToken ct = default) + { + List results = []; + + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + // Look up the catalog entry for this component to find its companions. + + CatalogEntry? entry = ComponentCatalog.GetByKey(component.Name); + + if (entry is null || entry.CompanionKeys.Count == 0) + { + return results; + } + + // For each companion, check if it already exists on this cluster. + // If not, register it and run a full install cycle. + + foreach (string companionKey in entry.CompanionKeys) + { + CatalogEntry? companion = ComponentCatalog.GetByKey(companionKey); + + if (companion is null) + { + continue; + } + + bool alreadyExists = await db.ClusterComponents + .AnyAsync(c => c.ClusterId == component.ClusterId && c.Name == companionKey, ct); + + if (alreadyExists) + { + continue; + } + + // Register the companion component. + + ComponentRegistration registration = ComponentCatalog.ToRegistration(companion); + ClusterComponent companionComponent = await RegisterComponentAsync( + component.ClusterId, registration, ct); + + // Run the full install cycle: prepare → get command → execute → mark result. + + try + { + await PrepareInstallAsync(companionComponent.Id, ct); + HelmCommand command = await GetInstallCommandAsync(companionComponent.Id, ct); + HelmExecutionResult result = await ExecuteHelmAsync(companionComponent.Id, command, ct); + await MarkInstallResultAsync(companionComponent.Id, result.Success, + result.Success ? null : result.Output, ct); + + results.Add(result); + } + catch (Exception ex) + { + await MarkInstallResultAsync(companionComponent.Id, false, ex.Message, ct); + results.Add(new HelmExecutionResult { Success = false, Output = ex.Message }); + } + } + + return results; + } + + /// + /// Validates that a component can be uninstalled and transitions it to + /// Uninstalling status. Only installed or failed components can be uninstalled. + /// + public async Task PrepareUninstallAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + if (component.Status is ComponentStatus.NotInstalled) + { + throw new InvalidOperationException( + "Component is not installed. Nothing to uninstall."); + } + + if (component.Status is ComponentStatus.Installing or ComponentStatus.Uninstalling) + { + throw new InvalidOperationException( + "Component has an operation in progress. Wait for it to complete."); + } + + component.Status = ComponentStatus.Uninstalling; + component.LastError = null; + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Records the result of a Helm uninstall operation. On success, resets + /// the component to NotInstalled so it can be reinstalled later if needed. + /// + public async Task MarkUninstallResultAsync( + Guid componentId, bool success, string? error = null, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + if (success) + { + component.Status = ComponentStatus.NotInstalled; + component.InstalledAt = null; + component.LastError = null; + } + else + { + component.Status = ComponentStatus.Failed; + component.LastError = error; + } + + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Clears the LastError on a component without changing its status. + /// Used when a user dismisses an error notification — the component + /// stays in its current state, we just stop showing the old error. + /// + public async Task ClearErrorAsync(Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + component.LastError = null; + await db.SaveChangesAsync(ct); + } + + /// + /// Builds a HelmCommand for installing or upgrading a component. + /// Uses "upgrade --install" which is idempotent — installs if not present, + /// upgrades if already installed. For Manifest-type components, produces + /// a "kubectl-apply" operation instead (applies raw YAML to the cluster). + /// + public async Task GetInstallCommandAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .Include(c => c.Cluster) + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + // Manifest components are applied via kubectl, not helm. + // Their HelmValues field contains raw Kubernetes YAML manifests. + + if (component.ComponentType == "Manifest") + { + return new HelmCommand + { + Operation = "kubectl-apply", + ReleaseName = component.ReleaseName ?? component.Name, + Namespace = component.Namespace, + HasValues = !string.IsNullOrWhiteSpace(component.HelmValues), + ValuesYaml = component.HelmValues + }; + } + + // Resolve any vault secrets and inject them into the values YAML. + // Secret form fields (like Grafana admin password) are stored encrypted + // in the vault rather than in plain text in HelmValues. At install time, + // we decrypt them and merge into the YAML so Helm gets the full picture. + + string? valuesYaml = await InjectSecretsIntoValuesAsync(component, ct); + + string releaseName = component.ReleaseName ?? component.Name; + string chartRef = !string.IsNullOrWhiteSpace(component.HelmRepoUrl) + ? $"{component.HelmRepoUrl}/{component.HelmChartName}" + : component.HelmChartName ?? component.Name; + + return new HelmCommand + { + Operation = "upgrade --install", + ReleaseName = releaseName, + ChartReference = chartRef, + Namespace = component.Namespace, + RepoUrl = component.HelmRepoUrl, + Version = component.HelmChartVersion, + HasValues = !string.IsNullOrWhiteSpace(valuesYaml), + ValuesYaml = valuesYaml + }; + } + + /// + /// Builds a HelmCommand for uninstalling a component. + /// For Manifest-type components, produces a "kubectl-delete" operation. + /// + public async Task GetUninstallCommandAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + string releaseName = component.ReleaseName ?? component.Name; + + // Manifest components are deleted via kubectl, not helm uninstall. + + if (component.ComponentType == "Manifest") + { + return new HelmCommand + { + Operation = "kubectl-delete", + ReleaseName = releaseName, + Namespace = component.Namespace, + HasValues = !string.IsNullOrWhiteSpace(component.HelmValues), + ValuesYaml = component.HelmValues + }; + } + + return new HelmCommand + { + Operation = "uninstall", + ReleaseName = releaseName, + Namespace = component.Namespace + }; + } + + /// + /// Resolves vault secrets for a component and merges them into the Helm values YAML. + /// Looks up the component's catalog entry to find which form fields are secret-backed, + /// then decrypts the corresponding vault secrets and injects them at the correct YAML paths. + /// Returns the merged YAML (or the original if no secrets exist). + /// + private async Task InjectSecretsIntoValuesAsync( + ClusterComponent component, CancellationToken ct) + { + // Look up the catalog entry to know which fields are secret-backed. + + CatalogEntry? catalog = ComponentCatalog.GetByKey(component.Name); + + if (catalog is null) + { + return component.HelmValues; + } + + List secretFields = catalog.FormFields + .Where(f => f.StoreAsSecret) + .ToList(); + + if (secretFields.Count == 0) + { + return component.HelmValues; + } + + // Retrieve each secret from the vault and build a path → value dictionary. + // If a secret is missing from the vault but exists in the component's HelmValues + // (e.g. imported release), extract it and store it in the vault so future + // operations don't lose it. + + Guid tenantId = component.Cluster.TenantId; + Dictionary secretPathValues = new(); + + foreach (ComponentFormField field in secretFields) + { + string secretName = field.SecretName ?? field.Key; + string? secretValue = await vaultService.GetComponentSecretValueAsync( + tenantId, component.Id, secretName, ct); + + if (string.IsNullOrEmpty(secretValue) && !string.IsNullOrWhiteSpace(component.HelmValues)) + { + // Secret missing from vault — try to recover it from the stored Helm values. + // This handles imported releases where secrets exist in the config but were + // never stored in the vault. + + string? existingValue = YamlFormMerger.ExtractValue(component.HelmValues, field.YamlPath); + + if (!string.IsNullOrEmpty(existingValue)) + { + await vaultService.SetComponentSecretAsync(tenantId, component.Id, secretName, existingValue, ct); + secretValue = existingValue; + } + } + + if (!string.IsNullOrEmpty(secretValue)) + { + secretPathValues[field.YamlPath] = secretValue; + } + } + + if (secretPathValues.Count == 0) + { + return component.HelmValues; + } + + // Merge secrets into the values YAML alongside any existing config. + + string baseYaml = component.HelmValues ?? ""; + return YamlFormMerger.MergeFormValues(baseYaml, secretPathValues); + } + + /// + /// Syncs all component secrets marked SyncToKubernetes=true to the cluster + /// as Kubernetes Secret resources. Groups secrets by target K8s Secret name + /// and namespace, then creates or updates each Secret resource via the K8s API. + /// + /// This is called after a successful install/upgrade so that the component's + /// pods can mount or reference the secrets. Can also be triggered manually + /// from the UI to re-sync after secret changes. + /// + public async Task SyncComponentSecretsAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Load the component with its cluster to get kubeconfig and tenant ID. + + ClusterComponent component = await db.ClusterComponents + .Include(c => c.Cluster) + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + if (string.IsNullOrWhiteSpace(component.Cluster.Kubeconfig)) + { + return new HelmExecutionResult + { + Success = false, + Output = "No kubeconfig stored for this cluster." + }; + } + + // Retrieve all secrets for this component that are marked for K8s sync. + + Guid tenantId = component.Cluster.TenantId; + List allSecrets = await db.Set() + .Where(s => s.ComponentId == componentId && s.SyncToKubernetes) + .ToListAsync(ct); + + if (allSecrets.Count == 0) + { + return new HelmExecutionResult + { + Success = true, + Output = "No secrets marked for Kubernetes sync." + }; + } + + // Group secrets by their target K8s Secret name + namespace. + // Multiple vault secrets can be keys in the same K8s Secret resource. + + IEnumerable> groups = allSecrets + .Where(s => !string.IsNullOrWhiteSpace(s.KubernetesSecretName)) + .GroupBy(s => ( + SecretName: s.KubernetesSecretName!, + Namespace: s.KubernetesNamespace ?? component.Namespace ?? "default" + )); + + // Build kubectl apply commands for each K8s Secret. + // We create an Opaque secret with all grouped vault secret values as data keys. + + string tempKubeconfig = Path.Combine(Path.GetTempPath(), $"entkube-{Guid.NewGuid()}.kubeconfig"); + List results = []; + + try + { + await File.WriteAllTextAsync(tempKubeconfig, component.Cluster.Kubeconfig, ct); + + foreach (IGrouping<(string SecretName, string Namespace), VaultSecret> group in groups) + { + string k8sSecretName = group.Key.SecretName; + string ns = group.Key.Namespace; + + // Decrypt each secret value and build --from-literal args. + + List literals = []; + + foreach (VaultSecret vaultSecret in group) + { + string? plainValue = await vaultService.GetComponentSecretValueAsync( + tenantId, componentId, vaultSecret.Name, ct); + + if (plainValue is not null) + { + literals.Add($"--from-literal={vaultSecret.Name}={plainValue}"); + } + } + + if (literals.Count == 0) + { + continue; + } + + // Delete existing secret (if any) then recreate. + // This is simpler than patch/merge for the common case. + + string deleteArgs = $"delete secret {k8sSecretName} --namespace {ns} --ignore-not-found --kubeconfig {tempKubeconfig}"; + await RunProcessAsync("kubectl", deleteArgs, ct); + + string createArgs = $"create secret generic {k8sSecretName} --namespace {ns} {string.Join(" ", literals)} --kubeconfig {tempKubeconfig}"; + HelmExecutionResult createResult = await RunProcessAsync("kubectl", createArgs, ct); + + if (createResult.Success) + { + results.Add($"✓ Secret '{k8sSecretName}' synced to namespace '{ns}' ({group.Count()} keys)"); + } + else + { + results.Add($"✗ Secret '{k8sSecretName}' failed: {createResult.Output}"); + } + } + + bool allSucceeded = results.All(r => r.StartsWith("✓")); + + return new HelmExecutionResult + { + Success = allSucceeded, + Output = string.Join("\n", results) + }; + } + finally + { + if (File.Exists(tempKubeconfig)) + { + File.Delete(tempKubeconfig); + } + } + } + + /// + /// Executes a Helm or kubectl command against a cluster using the stored kubeconfig. + /// For Helm operations: runs helm CLI with repo add, upgrade --install, or uninstall. + /// For Manifest operations: runs kubectl apply/delete with the YAML content. + /// Writes a temporary kubeconfig file, runs the CLI, and cleans up. + /// + public async Task ExecuteHelmAsync( + Guid componentId, HelmCommand command, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Load the cluster with its kubeconfig so we can authenticate. + + ClusterComponent component = await db.ClusterComponents + .Include(c => c.Cluster) + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + if (string.IsNullOrWhiteSpace(component.Cluster.Kubeconfig)) + { + return new HelmExecutionResult + { + Success = false, + Output = "No kubeconfig stored for this cluster." + }; + } + + // Write the kubeconfig to a temporary file. + + string tempKubeconfig = Path.Combine(Path.GetTempPath(), $"entkube-{Guid.NewGuid()}.kubeconfig"); + + try + { + await File.WriteAllTextAsync(tempKubeconfig, component.Cluster.Kubeconfig, ct); + + // Route to the appropriate executor based on operation type. + + if (command.Operation is "kubectl-apply" or "kubectl-delete") + { + return await ExecuteKubectlAsync(command, tempKubeconfig, ct); + } + + return await ExecuteHelmCliAsync(command, tempKubeconfig, component.Cluster.Kubeconfig, ct); + } + finally + { + if (File.Exists(tempKubeconfig)) + { + File.Delete(tempKubeconfig); + } + } + } + + /// + /// Applies or deletes Kubernetes manifests using kubectl. + /// The manifest YAML is written to a temp file and applied/deleted. + /// + private async Task ExecuteKubectlAsync( + HelmCommand command, string kubeconfigPath, CancellationToken ct) + { + if (!command.HasValues || string.IsNullOrWhiteSpace(command.ValuesYaml)) + { + return new HelmExecutionResult + { + Success = false, + Output = "No manifest YAML content to apply." + }; + } + + // Write the manifest YAML to a temp file. + + string tempManifest = Path.Combine(Path.GetTempPath(), $"entkube-manifest-{Guid.NewGuid()}.yaml"); + + try + { + await File.WriteAllTextAsync(tempManifest, command.ValuesYaml, ct); + + string operation = command.Operation == "kubectl-apply" ? "apply" : "delete"; + List args = [operation, "-f", tempManifest, "--kubeconfig", kubeconfigPath]; + + if (!string.IsNullOrWhiteSpace(command.Namespace)) + { + args.Add("--namespace"); + args.Add(command.Namespace); + } + + // For delete, don't fail if the resource doesn't exist. + + if (operation == "delete") + { + args.Add("--ignore-not-found"); + } + + string arguments = string.Join(" ", args); + return await RunProcessAsync("kubectl", arguments, ct); + } + finally + { + if (File.Exists(tempManifest)) + { + File.Delete(tempManifest); + } + } + } + + /// + /// Executes a Helm CLI command — handles repo add, upgrade --install, or uninstall. + /// When no repo URL is configured for an existing release, extracts the chart + /// from the Helm release secret on the cluster and uses it as a local chart path. + /// + private async Task ExecuteHelmCliAsync( + HelmCommand command, string kubeconfigPath, string kubeconfig, CancellationToken ct) + { + // Build the helm command arguments. + + List args = [command.Operation]; + int chartRefIndex = -1; + + if (command.Operation == "uninstall") + { + args.Add(command.ReleaseName); + } + else + { + args.Add(command.ReleaseName); + if (!string.IsNullOrWhiteSpace(command.ChartReference)) + { + chartRefIndex = args.Count; + args.Add(command.ChartReference); + } + } + + if (!string.IsNullOrWhiteSpace(command.Namespace)) + { + args.Add("--namespace"); + args.Add(command.Namespace); + + if (command.Operation != "uninstall") + { + args.Add("--create-namespace"); + } + } + + if (!string.IsNullOrWhiteSpace(command.Version)) + { + args.Add("--version"); + args.Add(command.Version); + } + + // If there are custom values, write them to a temp file. + + string? tempValuesFile = null; + string? tempChartDir = null; + + try + { + if (command.HasValues && !string.IsNullOrWhiteSpace(command.ValuesYaml)) + { + tempValuesFile = Path.Combine(Path.GetTempPath(), $"entkube-values-{Guid.NewGuid()}.yaml"); + await File.WriteAllTextAsync(tempValuesFile, command.ValuesYaml, ct); + args.Add("--values"); + args.Add(tempValuesFile); + } + + args.Add("--kubeconfig"); + args.Add(kubeconfigPath); + args.Add("--wait"); + args.Add("--timeout"); + args.Add("5m0s"); + + // If there's a repo URL, add the repo first and resolve the chart reference. + + if (!string.IsNullOrWhiteSpace(command.RepoUrl) && command.Operation != "uninstall") + { + string repoName = $"entkube-{command.ReleaseName}"; + await RunProcessAsync("helm", $"repo add {repoName} {command.RepoUrl} --force-update --kubeconfig {kubeconfigPath}", ct); + await RunProcessAsync("helm", $"repo update {repoName} --kubeconfig {kubeconfigPath}", ct); + + // Replace the chart reference with repo/chart format. + if (chartRefIndex >= 0) + { + args[chartRefIndex] = $"{repoName}/{command.ChartReference!.Split('/').Last()}"; + } + } + else if (string.IsNullOrWhiteSpace(command.RepoUrl) + && command.Operation != "uninstall" + && !string.IsNullOrWhiteSpace(command.ChartReference) + && !command.ChartReference.Contains('/') + && !command.ChartReference.StartsWith("oci://", StringComparison.OrdinalIgnoreCase)) + { + // No repo URL and the chart reference is a bare name (e.g. "kube-prometheus-stack"). + // Extract the chart from the existing Helm release secret on the cluster + // so we can use it as a local chart directory for the upgrade. + + tempChartDir = await ExtractChartFromReleaseAsync( + kubeconfig, command.ReleaseName, command.Namespace, ct); + + if (tempChartDir is not null && chartRefIndex >= 0) + { + args[chartRefIndex] = tempChartDir; + } + } + + string arguments = string.Join(" ", args); + return await RunProcessAsync("helm", arguments, ct); + } + finally + { + if (tempValuesFile is not null && File.Exists(tempValuesFile)) + { + File.Delete(tempValuesFile); + } + + if (tempChartDir is not null && Directory.Exists(tempChartDir)) + { + Directory.Delete(tempChartDir, recursive: true); + } + } + } + + /// + /// Extracts the full chart from a Helm release secret stored on the cluster. + /// Helm stores the chart (metadata, templates, default values) inside the release + /// data, so we can reconstruct a local chart directory without needing the repo URL. + /// Returns the path to a temp chart directory, or null if extraction failed. + /// + private static async Task ExtractChartFromReleaseAsync( + string kubeconfig, string releaseName, string? ns, CancellationToken ct) + { + try + { + using MemoryStream stream = new(Encoding.UTF8.GetBytes(kubeconfig)); + KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream); + using Kubernetes client = new(config); + + // Helm stores releases as secrets with label owner=helm, name=. + // The latest revision is the one with the highest version number. + + string targetNs = ns ?? "default"; + V1SecretList secrets = await client.ListNamespacedSecretAsync( + targetNs, + labelSelector: $"owner=helm,name={releaseName}", + cancellationToken: ct); + + if (secrets.Items.Count == 0) + { + return null; + } + + // Find the latest revision by sorting on the "version" label. + + V1Secret latest = secrets.Items + .OrderByDescending(s => + s.Metadata.Labels.TryGetValue("version", out string? v) && int.TryParse(v, out int ver) ? ver : 0) + .First(); + + if (latest.Data is null || !latest.Data.TryGetValue("release", out byte[]? rawData) || rawData is null) + { + return null; + } + + // Decode: UTF-8 → base64 → gzip → JSON (same format as ComponentScanService). + + string helmBase64 = Encoding.UTF8.GetString(rawData); + byte[] gzipped = Convert.FromBase64String(helmBase64); + + using MemoryStream compressedStream = new(gzipped); + using GZipStream gzipStream = new(compressedStream, CompressionMode.Decompress); + using MemoryStream decompressedStream = new(); + await gzipStream.CopyToAsync(decompressedStream, ct); + byte[] jsonBytes = decompressedStream.ToArray(); + + using JsonDocument doc = JsonDocument.Parse(jsonBytes); + JsonElement root = doc.RootElement; + + if (!root.TryGetProperty("chart", out JsonElement chart)) + { + return null; + } + + // Create a temp chart directory and write Chart.yaml + templates. + + string chartDir = Path.Combine(Path.GetTempPath(), $"entkube-chart-{Guid.NewGuid()}"); + Directory.CreateDirectory(chartDir); + + // Write Chart.yaml from metadata. + + if (chart.TryGetProperty("metadata", out JsonElement metadata)) + { + StringBuilder chartYaml = new(); + chartYaml.AppendLine($"apiVersion: v2"); + + if (metadata.TryGetProperty("name", out JsonElement name)) + { + chartYaml.AppendLine($"name: {name.GetString()}"); + } + + if (metadata.TryGetProperty("version", out JsonElement version)) + { + chartYaml.AppendLine($"version: {version.GetString()}"); + } + + if (metadata.TryGetProperty("appVersion", out JsonElement appVersion)) + { + chartYaml.AppendLine($"appVersion: \"{appVersion.GetString()}\""); + } + + if (metadata.TryGetProperty("description", out JsonElement desc)) + { + chartYaml.AppendLine($"description: {desc.GetString()}"); + } + + if (metadata.TryGetProperty("type", out JsonElement type)) + { + chartYaml.AppendLine($"type: {type.GetString()}"); + } + + await File.WriteAllTextAsync( + Path.Combine(chartDir, "Chart.yaml"), chartYaml.ToString(), ct); + } + + // Write default values.yaml. + + if (chart.TryGetProperty("values", out JsonElement values)) + { + await File.WriteAllTextAsync( + Path.Combine(chartDir, "values.yaml"), values.GetRawText(), ct); + } + + // Write templates. + + if (chart.TryGetProperty("templates", out JsonElement templates) + && templates.ValueKind == JsonValueKind.Array) + { + string templatesDir = Path.Combine(chartDir, "templates"); + Directory.CreateDirectory(templatesDir); + + foreach (JsonElement tmpl in templates.EnumerateArray()) + { + string? tmplName = tmpl.TryGetProperty("name", out JsonElement n) ? n.GetString() : null; + string? tmplData = tmpl.TryGetProperty("data", out JsonElement d) ? d.GetString() : null; + + if (tmplName is null || tmplData is null) + { + continue; + } + + // Template data is base64-encoded. + + byte[] tmplBytes = Convert.FromBase64String(tmplData); + string tmplPath = Path.Combine(templatesDir, tmplName.Replace("templates/", "")); + string? tmplDir = Path.GetDirectoryName(tmplPath); + + if (tmplDir is not null) + { + Directory.CreateDirectory(tmplDir); + } + + await File.WriteAllBytesAsync(tmplPath, tmplBytes, ct); + } + } + + // Write CRDs if present. + + if (chart.TryGetProperty("files", out JsonElement files) + && files.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement file in files.EnumerateArray()) + { + string? fileName = file.TryGetProperty("name", out JsonElement fn) ? fn.GetString() : null; + string? fileData = file.TryGetProperty("data", out JsonElement fd) ? fd.GetString() : null; + + if (fileName is null || fileData is null) + { + continue; + } + + byte[] fileBytes = Convert.FromBase64String(fileData); + string filePath = Path.Combine(chartDir, fileName); + string? fileDir = Path.GetDirectoryName(filePath); + + if (fileDir is not null) + { + Directory.CreateDirectory(fileDir); + } + + await File.WriteAllBytesAsync(filePath, fileBytes, ct); + } + } + + return chartDir; + } + catch + { + // If extraction fails for any reason, return null so the caller + // proceeds with the bare chart name (which will likely fail with + // a clear Helm error message). + return null; + } + } + + /// + /// Runs a CLI process (helm or kubectl) and captures its output. + /// + private static async Task RunProcessAsync( + string program, string arguments, CancellationToken ct) + { + ProcessStartInfo psi = new() + { + FileName = program, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new() { StartInfo = psi }; + + try + { + process.Start(); + + Task stdoutTask = process.StandardOutput.ReadToEndAsync(ct); + Task stderrTask = process.StandardError.ReadToEndAsync(ct); + + await process.WaitForExitAsync(ct); + + string stdout = await stdoutTask; + string stderr = await stderrTask; + + return new HelmExecutionResult + { + Success = process.ExitCode == 0, + ExitCode = process.ExitCode, + Output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout + }; + } + catch (Exception ex) + { + return new HelmExecutionResult + { + Success = false, + Output = $"Failed to run {program}: {ex.Message}" + }; + } + } +} + +/// +/// Result of executing a Helm CLI command against a cluster. +/// +public class HelmExecutionResult +{ + public bool Success { get; set; } + public int ExitCode { get; set; } + public string Output { get; set; } = ""; +} diff --git a/src/EntKube.Web/Services/ComponentScanService.cs b/src/EntKube.Web/Services/ComponentScanService.cs new file mode 100644 index 0000000..a558590 --- /dev/null +++ b/src/EntKube.Web/Services/ComponentScanService.cs @@ -0,0 +1,856 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using EntKube.Web.Data; +using k8s; +using k8s.Models; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// A Helm release discovered from a cluster by reading Helm's storage secrets. +/// Contains all the metadata needed to import the release as a ClusterComponent. +/// +public class DiscoveredHelmRelease +{ + public required string Name { get; set; } + public required string Namespace { get; set; } + public string? ChartName { get; set; } + public string? ChartVersion { get; set; } + public string? AppVersion { get; set; } + public string? Status { get; set; } + public int Revision { get; set; } + public string? Values { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// + /// True if a ClusterComponent with this name already exists on the cluster. + /// + public bool AlreadyTracked { get; set; } +} + +/// +/// Scans a Kubernetes cluster for installed Helm releases by reading the Helm +/// storage secrets. Helm 3 stores release state as Kubernetes Secrets with the +/// label "owner=helm" in the release's target namespace. +/// +/// The release data is encoded as: base64 (K8s) → base64 (Helm) → gzip → JSON. +/// This service decodes that chain to extract chart metadata, values, and status. +/// +/// Found releases can then be imported as ClusterComponents so the platform +/// tracks what's already running — even if it was installed outside EntKube. +/// +public class ComponentScanService(IDbContextFactory dbFactory, VaultService vaultService) +{ + /// + /// Scans the cluster for all installed Helm releases. Connects using the + /// cluster's stored kubeconfig, queries all namespaces for Helm secrets, + /// groups by release name (taking the latest revision), and decodes the + /// release metadata. + /// + /// Returns a list of discovered releases with an AlreadyTracked flag + /// indicating which ones are already registered as ClusterComponents. + /// + public async Task> ScanHelmReleasesAsync( + KubernetesCluster cluster, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + return []; + } + + // Connect to the cluster using its stored kubeconfig. + + Kubernetes client = CreateClient(cluster.Kubeconfig); + + // Query all namespaces for secrets labeled as Helm releases. + // Helm 3 creates secrets with label "owner=helm" for each revision. + + V1SecretList helmSecrets; + + try + { + helmSecrets = await client.CoreV1.ListSecretForAllNamespacesAsync( + labelSelector: "owner=helm", + cancellationToken: ct); + } + catch + { + // If we can't reach the cluster or lack permissions, return empty. + return []; + } + + // Group secrets by release name, pick the latest revision for each. + // Secret naming convention: sh.helm.release.v1..v + + List releases = []; + Dictionary latestByRelease = new(); + + foreach (V1Secret secret in helmSecrets.Items) + { + string? releaseName = secret.Metadata?.Labels?.TryGetValue("name", out string? rn) == true ? rn : null; + string? versionStr = secret.Metadata?.Labels?.TryGetValue("version", out string? vs) == true ? vs : null; + + if (releaseName is null || !int.TryParse(versionStr, out int revision)) + { + continue; + } + + string key = $"{secret.Metadata!.NamespaceProperty}/{releaseName}"; + + if (!latestByRelease.TryGetValue(key, out (V1Secret secret, int revision) existing) + || revision > existing.revision) + { + latestByRelease[key] = (secret, revision); + } + } + + // Get existing components on this cluster to mark already-tracked ones. + + List existingComponents = await vaultService.GetComponentsAsync(cluster.Id, ct); + HashSet trackedNames = existingComponents + .Select(c => c.Name.ToLowerInvariant()) + .ToHashSet(); + + // Decode each latest-revision release secret into a DiscoveredHelmRelease. + + foreach (KeyValuePair entry in latestByRelease) + { + (V1Secret secret, int revision) = entry.Value; + DiscoveredHelmRelease? release = DecodeHelmRelease(secret, revision); + + if (release is not null) + { + release.AlreadyTracked = trackedNames.Contains(release.Name.ToLowerInvariant()); + releases.Add(release); + } + } + + return releases.OrderBy(r => r.Namespace).ThenBy(r => r.Name).ToList(); + } + + /// + /// Imports a discovered Helm release as a ClusterComponent. Creates the + /// component with full lifecycle metadata (namespace, chart info, values, + /// status). If a component with the same name already exists, it updates + /// the existing one instead. Also discovers Ingress/HTTPRoute/LoadBalancer + /// resources and creates ExternalRoute entries. + /// + public async Task ImportReleaseAsync( + KubernetesCluster cluster, DiscoveredHelmRelease release, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Check if a component with this name already exists on the cluster. + + ClusterComponent? existing = await db.ClusterComponents + .FirstOrDefaultAsync(c => c.ClusterId == cluster.Id && c.Name == release.Name, ct); + + if (existing is not null) + { + // Update existing component with discovered state. + // If it matches a catalog entry, enrich with repo URL. + + CatalogEntry? catalog = ComponentCatalog.FindByRelease(release.Name, release.ChartName); + + existing.Namespace = release.Namespace; + existing.HelmChartName = catalog?.HelmChartName ?? release.ChartName; + existing.HelmChartVersion = release.ChartVersion; + existing.HelmValues = release.Values; + existing.ReleaseName = release.Name; + existing.Status = MapStatus(release.Status); + existing.InstalledAt = release.UpdatedAt; + + if (string.IsNullOrWhiteSpace(existing.HelmRepoUrl) && catalog is not null) + { + existing.HelmRepoUrl = catalog.HelmRepoUrl; + } + + await db.SaveChangesAsync(ct); + + // Extract secrets to vault if they don't already exist. + + await ExtractSecretsToVaultAsync(cluster.TenantId, existing, ct); + + // Discover exposed routes for existing component too. + // Route discovery is best-effort — failure shouldn't block import. + + try + { + await DiscoverRoutesAsync(cluster, existing, ct); + } + catch + { + // Route discovery failed (cluster unreachable, CRD missing, etc.) + } + + return existing; + } + + // Create a new component from the discovered release. + // If it matches a catalog entry, enrich with repo URL and correct chart name. + + CatalogEntry? catalogEntry = ComponentCatalog.FindByRelease(release.Name, release.ChartName); + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = cluster.Id, + Name = release.Name, + ComponentType = "HelmChart", + Namespace = release.Namespace, + HelmChartName = catalogEntry?.HelmChartName ?? release.ChartName, + HelmChartVersion = release.ChartVersion, + HelmRepoUrl = catalogEntry?.HelmRepoUrl, + ReleaseName = release.Name, + HelmValues = release.Values, + Status = MapStatus(release.Status), + InstalledAt = release.UpdatedAt + }; + + db.ClusterComponents.Add(component); + await db.SaveChangesAsync(ct); + + // If this component matches a catalog entry with secret fields, + // extract those secrets from the Helm values and store them in the vault. + + await ExtractSecretsToVaultAsync(cluster.TenantId, component, ct); + + // Discover exposed routes (Ingress, HTTPRoute, LoadBalancer) for the component. + // Route discovery is best-effort — failure shouldn't block import. + + try + { + await DiscoverRoutesAsync(cluster, component, ct); + } + catch + { + // Route discovery failed (cluster unreachable, CRD missing, etc.) + } + + return component; + } + + /// + /// Imports all non-tracked releases in a single operation. + /// Returns the count of newly imported components. Individual import + /// failures are collected and re-thrown as an aggregate after processing + /// all releases, so one bad release doesn't block the rest. + /// + public async Task ImportAllNewReleasesAsync( + KubernetesCluster cluster, List releases, CancellationToken ct = default) + { + int imported = 0; + List errors = []; + + foreach (DiscoveredHelmRelease release in releases.Where(r => !r.AlreadyTracked)) + { + try + { + await ImportReleaseAsync(cluster, release, ct); + release.AlreadyTracked = true; + imported++; + } + catch (Exception ex) + { + string detail = ex.InnerException?.Message ?? ex.Message; + errors.Add($"{release.Name} ({release.Namespace}): {detail}"); + } + } + + if (errors.Count > 0) + { + throw new InvalidOperationException( + $"Imported {imported} releases but {errors.Count} failed:\n{string.Join("\n", errors)}"); + } + + return imported; + } + + /// + /// Discovers Ingress and HTTPRoute resources in the component's namespace + /// that are related to the component (matched by release name label or + /// by backend service name containing the release name). Creates ExternalRoute + /// entries for each discovered hostname. + /// + /// This handles routes created by Helm charts (e.g. ingress-nginx values, + /// Traefik IngressRoute converted to Ingress, etc.) regardless of whether + /// EntKube originally created them. + /// + public async Task> DiscoverRoutesAsync( + KubernetesCluster cluster, ClusterComponent component, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig) || string.IsNullOrWhiteSpace(component.Namespace)) + { + return []; + } + + Kubernetes client = CreateClient(cluster.Kubeconfig); + List discovered = []; + + // Get existing routes for this component so we don't duplicate. + + HashSet existingHostnames = (await db.ExternalRoutes + .Where(r => r.ComponentId == component.Id) + .Select(r => r.Hostname) + .ToListAsync(ct)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // ── Scan Ingress resources ── + + try + { + V1IngressList ingresses = await client.NetworkingV1.ListNamespacedIngressAsync( + component.Namespace, cancellationToken: ct); + + foreach (V1Ingress ingress in ingresses.Items) + { + // Match ingresses that belong to this release by label or name. + + if (!BelongsToRelease(ingress.Metadata, component.ReleaseName ?? component.Name)) + { + continue; + } + + // Extract each rule's host and backend. + + if (ingress.Spec?.Rules is null) + { + continue; + } + + foreach (V1IngressRule rule in ingress.Spec.Rules) + { + if (string.IsNullOrWhiteSpace(rule.Host) || existingHostnames.Contains(rule.Host)) + { + continue; + } + + // Find the primary backend service and port from the first path. + + string? serviceName = null; + int servicePort = 80; + + if (rule.Http?.Paths is { Count: > 0 }) + { + V1IngressServiceBackend? backend = rule.Http.Paths[0].Backend?.Service; + + if (backend is not null) + { + serviceName = backend.Name; + servicePort = backend.Port?.Number ?? 80; + } + } + + // Determine TLS mode from ingress TLS config. + + TlsMode tlsMode = TlsMode.ClusterIssuer; + string? issuerName = null; + + if (ingress.Metadata?.Annotations is not null) + { + // cert-manager annotation pattern. + + if (ingress.Metadata.Annotations.TryGetValue( + "cert-manager.io/cluster-issuer", out string? issuer)) + { + issuerName = issuer; + } + else if (ingress.Metadata.Annotations.TryGetValue( + "cert-manager.io/issuer", out string? nsIssuer)) + { + issuerName = nsIssuer; + } + } + + bool hasTls = ingress.Spec.Tls?.Any(t => + t.Hosts?.Contains(rule.Host) == true) ?? false; + + if (hasTls && issuerName is null) + { + tlsMode = TlsMode.Manual; + } + + ExternalRoute route = new() + { + Id = Guid.NewGuid(), + ComponentId = component.Id, + Hostname = rule.Host, + ServiceName = serviceName, + ServicePort = servicePort, + TlsMode = tlsMode, + ClusterIssuerName = issuerName ?? "letsencrypt-prod", + CreatedAt = DateTime.UtcNow + }; + + discovered.Add(route); + existingHostnames.Add(rule.Host); + } + } + } + catch + { + // Ingress API not available or permission denied — skip. + } + + // ── Scan HTTPRoute resources (Gateway API) ── + + try + { + // HTTPRoute is a CRD — query via generic client. + + object httpRoutesRaw = await client.CustomObjects.ListNamespacedCustomObjectAsync( + "gateway.networking.k8s.io", "v1", component.Namespace, "httproutes", + cancellationToken: ct); + + if (httpRoutesRaw is JsonElement routeListElement) + { + ParseHttpRoutes(routeListElement, component, existingHostnames, discovered); + } + else + { + // The K8s client returns different types depending on version. + // Try parsing as JSON string. + + string json = JsonSerializer.Serialize(httpRoutesRaw); + using JsonDocument doc = JsonDocument.Parse(json); + ParseHttpRoutes(doc.RootElement, component, existingHostnames, discovered); + } + } + catch + { + // Gateway API CRDs not installed or permission denied — skip. + } + + // ── Scan LoadBalancer services for direct external IPs ── + + try + { + V1ServiceList services = await client.CoreV1.ListNamespacedServiceAsync( + component.Namespace, cancellationToken: ct); + + foreach (V1Service svc in services.Items) + { + if (svc.Spec?.Type != "LoadBalancer") + { + continue; + } + + if (!BelongsToRelease(svc.Metadata, component.ReleaseName ?? component.Name)) + { + continue; + } + + // Extract external IP or hostname from status. + + string? externalHost = svc.Status?.LoadBalancer?.Ingress?.FirstOrDefault()?.Hostname + ?? svc.Status?.LoadBalancer?.Ingress?.FirstOrDefault()?.Ip; + + if (string.IsNullOrWhiteSpace(externalHost) || existingHostnames.Contains(externalHost)) + { + continue; + } + + int port = svc.Spec.Ports?.FirstOrDefault()?.Port ?? 80; + + ExternalRoute route = new() + { + Id = Guid.NewGuid(), + ComponentId = component.Id, + Hostname = externalHost, + ServiceName = svc.Metadata?.Name, + ServicePort = port, + TlsMode = TlsMode.Manual, + CreatedAt = DateTime.UtcNow + }; + + discovered.Add(route); + existingHostnames.Add(externalHost); + } + } + catch + { + // Service list failed — skip. + } + + // Persist all discovered routes. + + if (discovered.Count > 0) + { + db.ExternalRoutes.AddRange(discovered); + await db.SaveChangesAsync(ct); + } + + return discovered; + } + + // ──────── Internal ──────── + + /// + /// Checks whether a Kubernetes resource belongs to a Helm release by examining + /// common labels (app.kubernetes.io/instance, helm.sh/release-name) or by + /// checking if the resource name starts with the release name. + /// + private static bool BelongsToRelease(V1ObjectMeta? metadata, string releaseName) + { + if (metadata is null) + { + return false; + } + + string releaseNameLower = releaseName.ToLowerInvariant(); + + // Check standard Helm/Kubernetes labels. + + if (metadata.Labels is not null) + { + if (metadata.Labels.TryGetValue("app.kubernetes.io/instance", out string? instance) + && string.Equals(instance, releaseName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (metadata.Labels.TryGetValue("release", out string? rel) + && string.Equals(rel, releaseName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (metadata.Labels.TryGetValue("helm.sh/release-name", out string? helmRel) + && string.Equals(helmRel, releaseName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + // Fallback: resource name starts with the release name (common Helm pattern). + + if (metadata.Name?.ToLowerInvariant().StartsWith(releaseNameLower) == true) + { + return true; + } + + return false; + } + + /// + /// Parses HTTPRoute resources from a Gateway API custom object list response. + /// + private static void ParseHttpRoutes( + JsonElement routeList, ClusterComponent component, + HashSet existingHostnames, List discovered) + { + if (!routeList.TryGetProperty("items", out JsonElement items) || items.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (JsonElement route in items.EnumerateArray()) + { + // Check if this route belongs to the release. + + bool matches = false; + + if (route.TryGetProperty("metadata", out JsonElement meta) + && meta.TryGetProperty("labels", out JsonElement labels)) + { + if (labels.TryGetProperty("app.kubernetes.io/instance", out JsonElement inst) + && string.Equals(inst.GetString(), component.ReleaseName ?? component.Name, + StringComparison.OrdinalIgnoreCase)) + { + matches = true; + } + else if (labels.TryGetProperty("helm.sh/release-name", out JsonElement helmName) + && string.Equals(helmName.GetString(), component.ReleaseName ?? component.Name, + StringComparison.OrdinalIgnoreCase)) + { + matches = true; + } + } + + if (!matches && route.TryGetProperty("metadata", out JsonElement m2) + && m2.TryGetProperty("name", out JsonElement nameEl)) + { + string? routeName = nameEl.GetString(); + + if (routeName?.StartsWith(component.ReleaseName ?? component.Name, + StringComparison.OrdinalIgnoreCase) == true) + { + matches = true; + } + } + + if (!matches) + { + continue; + } + + // Extract hostnames from spec.hostnames. + + if (!route.TryGetProperty("spec", out JsonElement spec)) + { + continue; + } + + if (spec.TryGetProperty("hostnames", out JsonElement hostnames) + && hostnames.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement hostname in hostnames.EnumerateArray()) + { + string? host = hostname.GetString(); + + if (string.IsNullOrWhiteSpace(host) || existingHostnames.Contains(host)) + { + continue; + } + + // Try to find backend service from rules. + + string? serviceName = null; + int servicePort = 80; + + if (spec.TryGetProperty("rules", out JsonElement rules) + && rules.ValueKind == JsonValueKind.Array + && rules.GetArrayLength() > 0) + { + JsonElement firstRule = rules[0]; + + if (firstRule.TryGetProperty("backendRefs", out JsonElement backendRefs) + && backendRefs.ValueKind == JsonValueKind.Array + && backendRefs.GetArrayLength() > 0) + { + JsonElement firstBackend = backendRefs[0]; + serviceName = firstBackend.TryGetProperty("name", out JsonElement sn) + ? sn.GetString() : null; + servicePort = firstBackend.TryGetProperty("port", out JsonElement sp) + ? sp.GetInt32() : 80; + } + } + + // Extract gateway reference. + + string? gatewayName = null; + string? gatewayNamespace = null; + + if (spec.TryGetProperty("parentRefs", out JsonElement parentRefs) + && parentRefs.ValueKind == JsonValueKind.Array + && parentRefs.GetArrayLength() > 0) + { + JsonElement firstParent = parentRefs[0]; + gatewayName = firstParent.TryGetProperty("name", out JsonElement gn) + ? gn.GetString() : null; + gatewayNamespace = firstParent.TryGetProperty("namespace", out JsonElement gns) + ? gns.GetString() : null; + } + + ExternalRoute route2 = new() + { + Id = Guid.NewGuid(), + ComponentId = component.Id, + Hostname = host, + ServiceName = serviceName, + ServicePort = servicePort, + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod", + GatewayName = gatewayName, + GatewayNamespace = gatewayNamespace, + CreatedAt = DateTime.UtcNow + }; + + discovered.Add(route2); + existingHostnames.Add(host); + } + } + } + } + + /// + /// Decodes a Helm release secret into a DiscoveredHelmRelease. + /// The encoding chain is: K8s base64 → Helm base64 → gzip → JSON. + /// + private static DiscoveredHelmRelease? DecodeHelmRelease(V1Secret secret, int revision) + { + if (secret.Data is null || !secret.Data.TryGetValue("release", out byte[]? rawData)) + { + return null; + } + + try + { + // Step 1: K8s already base64-decoded the Secret data for us. + // Step 2: Helm base64-encodes the release data before storing. + + string helmBase64 = Encoding.UTF8.GetString(rawData); + byte[] gzipped = Convert.FromBase64String(helmBase64); + + // Step 3: Decompress gzip. + + using MemoryStream compressedStream = new(gzipped); + using GZipStream gzipStream = new(compressedStream, CompressionMode.Decompress); + using MemoryStream decompressedStream = new(); + gzipStream.CopyTo(decompressedStream); + byte[] jsonBytes = decompressedStream.ToArray(); + + // Step 4: Parse JSON release object. + + using JsonDocument doc = JsonDocument.Parse(jsonBytes); + JsonElement root = doc.RootElement; + + string releaseName = root.GetProperty("name").GetString() ?? "unknown"; + string ns = root.GetProperty("namespace").GetString() ?? "default"; + + // Chart metadata lives under root.chart.metadata. + + string? chartName = null; + string? chartVersion = null; + string? appVersion = null; + + if (root.TryGetProperty("chart", out JsonElement chart) + && chart.TryGetProperty("metadata", out JsonElement metadata)) + { + chartName = metadata.TryGetProperty("name", out JsonElement cn) ? cn.GetString() : null; + chartVersion = metadata.TryGetProperty("version", out JsonElement cv) ? cv.GetString() : null; + appVersion = metadata.TryGetProperty("appVersion", out JsonElement av) ? av.GetString() : null; + } + + // Status from info.status. + + string? status = null; + DateTime? updatedAt = null; + + if (root.TryGetProperty("info", out JsonElement info)) + { + status = info.TryGetProperty("status", out JsonElement st) ? st.GetString() : null; + + if (info.TryGetProperty("last_deployed", out JsonElement ld)) + { + if (DateTime.TryParse(ld.GetString(), out DateTime parsed)) + { + updatedAt = parsed.Kind == DateTimeKind.Utc + ? parsed + : parsed.ToUniversalTime(); + } + } + } + + // User-supplied values (config). + + string? values = null; + + if (root.TryGetProperty("config", out JsonElement config) + && config.ValueKind == JsonValueKind.Object) + { + // Convert to YAML-friendly JSON string. In a real scenario we might + // convert to YAML, but JSON is sufficient for storage and display. + + values = config.GetRawText(); + + // Empty config {} means no custom values. + + if (values == "{}") + { + values = null; + } + } + + return new DiscoveredHelmRelease + { + Name = releaseName, + Namespace = ns, + ChartName = chartName, + ChartVersion = chartVersion, + AppVersion = appVersion, + Status = status, + Revision = revision, + Values = values, + UpdatedAt = updatedAt + }; + } + catch + { + // If decoding fails for any reason, build a minimal release from labels. + + string? fallbackName = secret.Metadata?.Labels?.TryGetValue("name", out string? fn) == true ? fn : null; + string? fallbackStatus = secret.Metadata?.Labels?.TryGetValue("status", out string? fs) == true ? fs : null; + + return new DiscoveredHelmRelease + { + Name = fallbackName ?? "unknown", + Namespace = secret.Metadata?.NamespaceProperty ?? "default", + Status = fallbackStatus, + Revision = revision + }; + } + } + + private static ComponentStatus MapStatus(string? helmStatus) + { + return helmStatus?.ToLowerInvariant() switch + { + "deployed" => ComponentStatus.Installed, + "failed" => ComponentStatus.Failed, + "pending-install" or "pending-upgrade" or "pending-rollback" => ComponentStatus.Installing, + "uninstalling" => ComponentStatus.Uninstalling, + _ => ComponentStatus.NotInstalled + }; + } + + /// + /// If the imported component matches a catalog entry with secret-backed fields, + /// extracts those values from HelmValues and stores them in the tenant vault. + /// Only stores secrets that don't already exist — won't overwrite manually set values. + /// + private async Task ExtractSecretsToVaultAsync( + Guid tenantId, ClusterComponent component, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(component.HelmValues)) + { + return; + } + + CatalogEntry? catalog = ComponentCatalog.FindByRelease(component.Name, component.HelmChartName); + + if (catalog is null) + { + return; + } + + List secretFields = catalog.FormFields + .Where(f => f.StoreAsSecret) + .ToList(); + + foreach (ComponentFormField field in secretFields) + { + string secretName = field.SecretName ?? field.Key; + + // Only store if the vault doesn't already have this secret. + + string? existing = await vaultService.GetComponentSecretValueAsync( + tenantId, component.Id, secretName, ct); + + if (!string.IsNullOrEmpty(existing)) + { + continue; + } + + // Extract the value from the stored Helm values YAML/JSON. + + string? value = YamlFormMerger.ExtractValue(component.HelmValues, field.YamlPath); + + if (!string.IsNullOrEmpty(value)) + { + await vaultService.SetComponentSecretAsync(tenantId, component.Id, secretName, value, ct); + } + } + } + + private static Kubernetes CreateClient(string kubeconfig) + { + using MemoryStream stream = new(Encoding.UTF8.GetBytes(kubeconfig)); + KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream); + return new Kubernetes(config); + } +} diff --git a/src/EntKube.Web/Services/CustomerAccessService.cs b/src/EntKube.Web/Services/CustomerAccessService.cs new file mode 100644 index 0000000..4796179 --- /dev/null +++ b/src/EntKube.Web/Services/CustomerAccessService.cs @@ -0,0 +1,141 @@ +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Manages which users can access which customers through the customer portal. +/// A tenant administrator grants access to users, specifying a role (Viewer, +/// Operator, Admin) that controls what they can do. +/// +/// When a user visits the portal, this service determines which customers they +/// can see and what actions they're allowed to perform. +/// +public class CustomerAccessService(IDbContextFactory dbFactory) +{ + /// + /// Grants a user access to a customer with a specific role. The default + /// role is Viewer, which lets them browse apps, deployments, and logs + /// without modifying anything. + /// + public async Task GrantAccessAsync( + string userId, + Guid customerId, + CustomerAccessRole role = CustomerAccessRole.Viewer, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + bool exists = await db.CustomerAccesses.AnyAsync( + a => a.UserId == userId && a.CustomerId == customerId, ct); + + if (exists) + { + throw new InvalidOperationException( + $"User '{userId}' already has access to customer '{customerId}'."); + } + + CustomerAccess access = new() + { + UserId = userId, + CustomerId = customerId, + Role = role + }; + + db.CustomerAccesses.Add(access); + await db.SaveChangesAsync(ct); + return access; + } + + /// + /// Revokes a user's access to a customer. After this, they can no longer + /// see the customer in the portal. + /// + public async Task RevokeAccessAsync( + string userId, Guid customerId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CustomerAccess? access = await db.CustomerAccesses.FindAsync([userId, customerId], ct); + + if (access is not null) + { + db.CustomerAccesses.Remove(access); + await db.SaveChangesAsync(ct); + } + } + + /// + /// Changes a user's role for a customer. For example, promoting a Viewer + /// to Operator so they can restart pods and trigger redeployments. + /// + public async Task UpdateRoleAsync( + string userId, + Guid customerId, + CustomerAccessRole newRole, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + CustomerAccess? access = await db.CustomerAccesses.FindAsync([userId, customerId], ct); + + if (access is not null) + { + access.Role = newRole; + await db.SaveChangesAsync(ct); + } + } + + /// + /// Returns a user's access record for a specific customer, or null if + /// they don't have access. Used to check authorization before operations. + /// + public async Task GetAccessAsync( + string userId, Guid customerId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.CustomerAccesses.FindAsync([userId, customerId], ct); + } + + /// + /// Returns all customers a user has been granted access to, including + /// each customer's apps. This is the entry point for the portal — the + /// user picks a customer, then drills into apps and deployments. + /// + public async Task> GetAccessibleCustomersAsync( + string userId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Find all customer IDs this user has access to, then load the + // customers with their apps in a single query. + + return await db.CustomerAccesses + .Where(ca => ca.UserId == userId) + .Select(ca => ca.CustomerId) + .Join( + db.Customers.Include(c => c.Apps), + id => id, + c => c.Id, + (id, customer) => customer) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + /// + /// Returns all users who have access to a specific customer, along with + /// their roles. Used by tenant admins to manage access. + /// + public async Task> GetCustomerUsersAsync( + Guid customerId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.CustomerAccesses + .Include(ca => ca.User) + .Where(ca => ca.CustomerId == customerId) + .OrderBy(ca => ca.User.UserName) + .ToListAsync(ct); + } +} diff --git a/src/EntKube.Web/Services/DatabaseService.cs b/src/EntKube.Web/Services/DatabaseService.cs new file mode 100644 index 0000000..0647397 --- /dev/null +++ b/src/EntKube.Web/Services/DatabaseService.cs @@ -0,0 +1,418 @@ +using System.Text.Json; +using EntKube.Web.Data; +using k8s; +using k8s.Models; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// A discovered database cluster (CNPG or MongoDB) running on a Kubernetes cluster. +/// Contains the metadata we care about for display: name, namespace, status, size, +/// and which K8s cluster it belongs to. +/// +public class DatabaseClusterInfo +{ + public required string Name { get; set; } + public required string Namespace { get; set; } + public required string Type { get; set; } + public required string Status { get; set; } + public int Instances { get; set; } + public string? Version { get; set; } + public string? PrimaryPod { get; set; } + public string? Storage { get; set; } + public DateTime? CreatedAt { get; set; } + + /// + /// The K8s cluster this database cluster lives on. + /// + public required string ClusterName { get; set; } + public Guid ClusterId { get; set; } + + /// + /// The environment this database cluster belongs to (e.g. "production", "staging"). + /// + public required string EnvironmentName { get; set; } + + /// + /// Individual databases within this cluster (for CNPG: database names, + /// for MongoDB: the replica set databases). + /// + public List Databases { get; set; } = []; +} + +/// +/// Result of checking whether database operators are available on a tenant's clusters. +/// +public class DatabaseOperatorStatus +{ + public bool CnpgAvailable { get; set; } + public bool MongoDbAvailable { get; set; } + public string? CnpgClusterName { get; set; } + public string? MongoDbClusterName { get; set; } +} + +/// +/// Queries Kubernetes clusters for CNPG and MongoDB custom resources. +/// Discovers database clusters and their databases by reading CRDs directly +/// from the K8s API — no need for the database operators to expose REST APIs. +/// +/// Requires the respective operators to be installed: +/// - CloudNativePG: Cluster CRD (postgresql.cnpg.io/v1) +/// - MongoDB Community Operator: MongoDBCommunity CRD (mongodbcommunity.mongodb.com/v1) +/// +public class DatabaseService(IDbContextFactory dbFactory) +{ + // ──────── Operator Availability ──────── + + /// + /// Checks which database operators are installed across the tenant's clusters. + /// A tenant can use the Databases page only if at least one operator is installed. + /// + public async Task GetOperatorStatusAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Find all clusters belonging to this tenant that have the relevant components installed. + + List installedComponents = await db.ClusterComponents + .Include(c => c.Cluster) + .Where(c => c.Cluster.TenantId == tenantId + && c.Status == ComponentStatus.Installed + && (c.Name == "cloudnative-pg" || c.Name == "mongodb-operator")) + .ToListAsync(ct); + + ClusterComponent? cnpg = installedComponents.FirstOrDefault(c => c.Name == "cloudnative-pg"); + ClusterComponent? mongo = installedComponents.FirstOrDefault(c => c.Name == "mongodb-operator"); + + return new DatabaseOperatorStatus + { + CnpgAvailable = cnpg is not null, + MongoDbAvailable = mongo is not null, + CnpgClusterName = cnpg?.Cluster.Name, + MongoDbClusterName = mongo?.Cluster.Name + }; + } + + /// + /// Returns all Kubernetes clusters that have the CNPG operator installed. + /// Used by the UI to populate the target cluster dropdown when creating + /// a new managed CNPG cluster. + /// + public async Task> GetCnpgEnabledClustersAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KubernetesClusters + .Where(k => k.TenantId == tenantId + && k.Components.Any(c => c.Name == "cloudnative-pg" && c.Status == ComponentStatus.Installed)) + .OrderBy(k => k.Name) + .ToListAsync(ct); + } + + // ──────── CNPG Discovery ──────── + + /// + /// Discovers all CloudNativePG Cluster resources across the tenant's clusters. + /// Reads the postgresql.cnpg.io/v1 Cluster CRD from every cluster that has + /// the cloudnative-pg operator installed. + /// + public async Task> GetCnpgClustersAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Find all K8s clusters with CNPG installed. + + List clusters = await db.KubernetesClusters + .Include(k => k.Environment) + .Where(k => k.TenantId == tenantId + && k.Components.Any(c => c.Name == "cloudnative-pg" && c.Status == ComponentStatus.Installed)) + .ToListAsync(ct); + + List results = []; + + foreach (KubernetesCluster cluster in clusters) + { + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + continue; + } + + try + { + List clusterDbs = await QueryCnpgClustersAsync(cluster, ct); + results.AddRange(clusterDbs); + } + catch + { + // If we can't reach a cluster, skip it gracefully. + } + } + + return results; + } + + /// + /// Queries a single K8s cluster for CNPG Cluster custom resources. + /// + private static async Task> QueryCnpgClustersAsync( + KubernetesCluster cluster, CancellationToken ct) + { + using Kubernetes client = CreateClient(cluster.Kubeconfig!); + + // List all CNPG Cluster CRs across all namespaces. + + object response = await client.CustomObjects.ListClusterCustomObjectAsync( + group: "postgresql.cnpg.io", + version: "v1", + plural: "clusters", + cancellationToken: ct); + + JsonElement json = JsonSerializer.Deserialize( + JsonSerializer.Serialize(response)); + + List results = []; + + if (json.TryGetProperty("items", out JsonElement items)) + { + foreach (JsonElement item in items.EnumerateArray()) + { + DatabaseClusterInfo info = ParseCnpgCluster(item, cluster); + results.Add(info); + } + } + + return results; + } + + /// + /// Parses a single CNPG Cluster resource JSON into a DatabaseClusterInfo. + /// Extracts name, namespace, instances, status, storage, and database names. + /// + private static DatabaseClusterInfo ParseCnpgCluster(JsonElement item, KubernetesCluster cluster) + { + JsonElement metadata = item.GetProperty("metadata"); + JsonElement spec = item.GetProperty("spec"); + + string name = metadata.GetProperty("name").GetString() ?? "unknown"; + string ns = metadata.GetProperty("namespace").GetString() ?? "default"; + + int instances = spec.TryGetProperty("instances", out JsonElement instEl) + ? instEl.GetInt32() : 1; + + string? version = spec.TryGetProperty("imageName", out JsonElement imgEl) + ? imgEl.GetString() : null; + + string? storage = spec.TryGetProperty("storage", out JsonElement storageEl) + && storageEl.TryGetProperty("size", out JsonElement sizeEl) + ? sizeEl.GetString() : null; + + // Extract database names from the bootstrap or managed configuration. + + List databases = []; + + if (spec.TryGetProperty("bootstrap", out JsonElement bootstrap) + && bootstrap.TryGetProperty("initdb", out JsonElement initdb) + && initdb.TryGetProperty("database", out JsonElement dbNameEl)) + { + string? dbName = dbNameEl.GetString(); + + if (!string.IsNullOrEmpty(dbName)) + { + databases.Add(dbName); + } + } + + // Status extraction. + + string status = "Unknown"; + + if (item.TryGetProperty("status", out JsonElement statusEl)) + { + if (statusEl.TryGetProperty("phase", out JsonElement phaseEl)) + { + status = phaseEl.GetString() ?? "Unknown"; + } + + if (statusEl.TryGetProperty("currentPrimary", out JsonElement primaryEl)) + { + // primaryPod is available + } + } + + string? primaryPod = item.TryGetProperty("status", out JsonElement st) + && st.TryGetProperty("currentPrimary", out JsonElement pEl) + ? pEl.GetString() : null; + + DateTime? createdAt = metadata.TryGetProperty("creationTimestamp", out JsonElement tsEl) + ? tsEl.GetDateTime() : null; + + return new DatabaseClusterInfo + { + Name = name, + Namespace = ns, + Type = "PostgreSQL", + Status = status, + Instances = instances, + Version = version, + PrimaryPod = primaryPod, + Storage = storage, + CreatedAt = createdAt, + ClusterName = cluster.Name, + ClusterId = cluster.Id, + EnvironmentName = cluster.Environment.Name, + Databases = databases + }; + } + + // ──────── MongoDB Discovery ──────── + + /// + /// Discovers all MongoDB Community resources across the tenant's clusters. + /// Reads the mongodbcommunity.mongodb.com/v1 MongoDBCommunity CRD from every + /// cluster that has the mongodb-operator installed. + /// + public async Task> GetMongoDbClustersAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + List clusters = await db.KubernetesClusters + .Include(k => k.Environment) + .Where(k => k.TenantId == tenantId + && k.Components.Any(c => c.Name == "mongodb-operator" && c.Status == ComponentStatus.Installed)) + .ToListAsync(ct); + + List results = []; + + foreach (KubernetesCluster cluster in clusters) + { + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + continue; + } + + try + { + List clusterDbs = await QueryMongoDbClustersAsync(cluster, ct); + results.AddRange(clusterDbs); + } + catch + { + // If we can't reach a cluster, skip it gracefully. + } + } + + return results; + } + + /// + /// Queries a single K8s cluster for MongoDBCommunity custom resources. + /// + private static async Task> QueryMongoDbClustersAsync( + KubernetesCluster cluster, CancellationToken ct) + { + using Kubernetes client = CreateClient(cluster.Kubeconfig!); + + object response = await client.CustomObjects.ListClusterCustomObjectAsync( + group: "mongodbcommunity.mongodb.com", + version: "v1", + plural: "mongodbcommunity", + cancellationToken: ct); + + JsonElement json = JsonSerializer.Deserialize( + JsonSerializer.Serialize(response)); + + List results = []; + + if (json.TryGetProperty("items", out JsonElement items)) + { + foreach (JsonElement item in items.EnumerateArray()) + { + DatabaseClusterInfo info = ParseMongoDbCluster(item, cluster); + results.Add(info); + } + } + + return results; + } + + /// + /// Parses a single MongoDBCommunity resource JSON into a DatabaseClusterInfo. + /// + private static DatabaseClusterInfo ParseMongoDbCluster(JsonElement item, KubernetesCluster cluster) + { + JsonElement metadata = item.GetProperty("metadata"); + JsonElement spec = item.GetProperty("spec"); + + string name = metadata.GetProperty("name").GetString() ?? "unknown"; + string ns = metadata.GetProperty("namespace").GetString() ?? "default"; + + int members = spec.TryGetProperty("members", out JsonElement membersEl) + ? membersEl.GetInt32() : 1; + + string? version = spec.TryGetProperty("version", out JsonElement verEl) + ? verEl.GetString() : null; + + // Extract database users and their associated databases. + + List databases = []; + + if (spec.TryGetProperty("users", out JsonElement users)) + { + foreach (JsonElement user in users.EnumerateArray()) + { + if (user.TryGetProperty("db", out JsonElement dbEl)) + { + string? dbName = dbEl.GetString(); + + if (!string.IsNullOrEmpty(dbName) && !databases.Contains(dbName)) + { + databases.Add(dbName); + } + } + } + } + + // Status from the CR. + + string status = "Unknown"; + + if (item.TryGetProperty("status", out JsonElement statusEl) + && statusEl.TryGetProperty("phase", out JsonElement phaseEl)) + { + status = phaseEl.GetString() ?? "Unknown"; + } + + DateTime? createdAt = metadata.TryGetProperty("creationTimestamp", out JsonElement tsEl) + ? tsEl.GetDateTime() : null; + + return new DatabaseClusterInfo + { + Name = name, + Namespace = ns, + Type = "MongoDB", + Status = status, + Instances = members, + Version = version, + Storage = null, + CreatedAt = createdAt, + ClusterName = cluster.Name, + ClusterId = cluster.Id, + EnvironmentName = cluster.Environment.Name, + Databases = databases + }; + } + + // ──────── Internal ──────── + + private static Kubernetes CreateClient(string kubeconfig) + { + using MemoryStream stream = new(System.Text.Encoding.UTF8.GetBytes(kubeconfig)); + KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream); + return new Kubernetes(config); + } +} diff --git a/src/EntKube.Web/Services/DeploymentService.cs b/src/EntKube.Web/Services/DeploymentService.cs new file mode 100644 index 0000000..a33aff9 --- /dev/null +++ b/src/EntKube.Web/Services/DeploymentService.cs @@ -0,0 +1,319 @@ +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Manages the lifecycle of app deployments — creating, configuring, and tracking +/// deployments that target Kubernetes clusters. Supports three deployment types: +/// +/// - Manual: structured form entry → generated YAML manifests +/// - Yaml: raw K8s YAML documents pasted/uploaded by the user +/// - HelmChart: any Helm chart with dynamic values +/// +/// Also manages the ArgoCD-style resource tree that tracks live cluster state +/// for each deployment (sync status, health status, resource hierarchy). +/// +public class DeploymentService(IDbContextFactory dbFactory) +{ + // ══════════════════════════════════════════════════════════════ + // Deployment CRUD + // ══════════════════════════════════════════════════════════════ + + /// + /// Creates a new deployment for an app. The deployment starts in Unknown + /// sync/health status until resources are actually applied to the cluster. + /// For Helm deployments, pass the chart details; for Manual/Yaml, leave them null. + /// + public async Task CreateDeploymentAsync( + Guid appId, + string name, + DeploymentType type, + Guid environmentId, + Guid clusterId, + string ns, + string? helmRepoUrl = null, + string? helmChartName = null, + string? helmChartVersion = null, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + AppDeployment deployment = new() + { + Id = Guid.NewGuid(), + AppId = appId, + Name = name, + Type = type, + EnvironmentId = environmentId, + ClusterId = clusterId, + Namespace = ns, + HelmRepoUrl = helmRepoUrl, + HelmChartName = helmChartName, + HelmChartVersion = helmChartVersion + }; + + db.AppDeployments.Add(deployment); + await db.SaveChangesAsync(ct); + return deployment; + } + + /// + /// Returns all deployments for an app, including the related environment + /// and cluster so the UI can show where each deployment targets. + /// + public async Task> GetDeploymentsAsync(Guid appId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.AppDeployments + .Include(d => d.Environment) + .Include(d => d.Cluster) + .Where(d => d.AppId == appId) + .OrderBy(d => d.CreatedAt) + .ToListAsync(ct); + } + + /// + /// Deletes a deployment and all its manifests and tracked resources. + /// EF cascade delete handles the child entities. + /// + public async Task DeleteDeploymentAsync(Guid deploymentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + AppDeployment? deployment = await db.AppDeployments.FindAsync([deploymentId], ct); + + if (deployment is not null) + { + db.AppDeployments.Remove(deployment); + await db.SaveChangesAsync(ct); + } + } + + // ══════════════════════════════════════════════════════════════ + // Manifest CRUD + // ══════════════════════════════════════════════════════════════ + + /// + /// Adds a YAML manifest to a deployment. Manifests are applied in SortOrder + /// sequence — use lower numbers for foundational resources (PVCs, ConfigMaps) + /// and higher numbers for workloads (Deployments, Services). + /// + public async Task AddManifestAsync( + Guid deploymentId, + string kind, + string name, + string yamlContent, + int sortOrder, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + DeploymentManifest manifest = new() + { + Id = Guid.NewGuid(), + DeploymentId = deploymentId, + Kind = kind, + Name = name, + YamlContent = yamlContent, + SortOrder = sortOrder + }; + + db.DeploymentManifests.Add(manifest); + await db.SaveChangesAsync(ct); + return manifest; + } + + /// + /// Returns all manifests for a deployment, ordered by SortOrder so they + /// can be applied to the cluster in the correct sequence. + /// + public async Task> GetManifestsAsync( + Guid deploymentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.DeploymentManifests + .Where(m => m.DeploymentId == deploymentId) + .OrderBy(m => m.SortOrder) + .ToListAsync(ct); + } + + /// + /// Updates the YAML content of an existing manifest. Called when the user + /// edits a manifest in the UI or re-imports updated YAML. + /// + public async Task UpdateManifestAsync( + Guid manifestId, string yamlContent, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + DeploymentManifest? manifest = await db.DeploymentManifests.FindAsync([manifestId], ct); + + if (manifest is not null) + { + manifest.YamlContent = yamlContent; + manifest.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + } + + /// + /// Removes a single manifest from a deployment. + /// + public async Task DeleteManifestAsync(Guid manifestId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + DeploymentManifest? manifest = await db.DeploymentManifests.FindAsync([manifestId], ct); + + if (manifest is not null) + { + db.DeploymentManifests.Remove(manifest); + await db.SaveChangesAsync(ct); + } + } + + // ══════════════════════════════════════════════════════════════ + // Helm Values + // ══════════════════════════════════════════════════════════════ + + /// + /// Stores the Helm values YAML for a Helm chart deployment. These values + /// override the chart's defaults when the Helm release is installed/upgraded. + /// + public async Task UpdateHelmValuesAsync( + Guid deploymentId, string valuesYaml, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + AppDeployment? deployment = await db.AppDeployments.FindAsync([deploymentId], ct); + + if (deployment is not null) + { + deployment.HelmValues = valuesYaml; + await db.SaveChangesAsync(ct); + } + } + + // ══════════════════════════════════════════════════════════════ + // Deployment Status + // ══════════════════════════════════════════════════════════════ + + /// + /// Updates the overall sync and health status of a deployment. Called by + /// the cluster watcher after reconciling live state against desired state. + /// + public async Task UpdateDeploymentStatusAsync( + Guid deploymentId, + SyncStatus syncStatus, + HealthStatus healthStatus, + string? message = null, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + AppDeployment? deployment = await db.AppDeployments.FindAsync([deploymentId], ct); + + if (deployment is not null) + { + deployment.SyncStatus = syncStatus; + deployment.HealthStatus = healthStatus; + deployment.StatusMessage = message; + deployment.LastSyncedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + } + + // ══════════════════════════════════════════════════════════════ + // Resource Tree (ArgoCD-style) + // ══════════════════════════════════════════════════════════════ + + /// + /// Creates or updates a tracked resource in the cluster. Resources are + /// identified by (deploymentId, group, version, kind, name, namespace). + /// If a matching resource already exists, its status is updated in place. + /// This is how the cluster watcher reports live resource state. + /// + public async Task UpsertResourceAsync( + Guid deploymentId, + string group, + string version, + string kind, + string name, + string? ns, + SyncStatus syncStatus, + HealthStatus healthStatus, + string? message, + Guid? parentResourceId = null, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Look for an existing resource with the same identity. + DeploymentResource? existing = await db.DeploymentResources + .FirstOrDefaultAsync(r => + r.DeploymentId == deploymentId && + r.Group == group && + r.Version == version && + r.Kind == kind && + r.Name == name && + r.Namespace == ns, ct); + + if (existing is not null) + { + // Update the existing resource's status. + existing.SyncStatus = syncStatus; + existing.HealthStatus = healthStatus; + existing.StatusMessage = message; + existing.LastUpdatedAt = DateTime.UtcNow; + + if (parentResourceId is not null) + { + existing.ParentResourceId = parentResourceId; + } + + await db.SaveChangesAsync(ct); + return existing; + } + + // Create a new tracked resource. + DeploymentResource resource = new() + { + Id = Guid.NewGuid(), + DeploymentId = deploymentId, + Group = group, + Version = version, + Kind = kind, + Name = name, + Namespace = ns, + SyncStatus = syncStatus, + HealthStatus = healthStatus, + StatusMessage = message, + ParentResourceId = parentResourceId + }; + + db.DeploymentResources.Add(resource); + await db.SaveChangesAsync(ct); + return resource; + } + + /// + /// Returns the root-level resources for a deployment with their children + /// eagerly loaded. This gives the UI the full resource tree for rendering + /// the ArgoCD-style resource hierarchy. + /// + public async Task> GetResourceTreeAsync( + Guid deploymentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.DeploymentResources + .Include(r => r.ChildResources) + .Where(r => r.DeploymentId == deploymentId && r.ParentResourceId == null) + .OrderBy(r => r.Kind) + .ThenBy(r => r.Name) + .ToListAsync(ct); + } +} diff --git a/src/EntKube.Web/Services/ExternalRouteService.cs b/src/EntKube.Web/Services/ExternalRouteService.cs new file mode 100644 index 0000000..28f7f58 --- /dev/null +++ b/src/EntKube.Web/Services/ExternalRouteService.cs @@ -0,0 +1,312 @@ +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Input model for creating or updating an external route. +/// Captures everything needed to expose a component externally. +/// +public class ExternalRouteRequest +{ + public required string Hostname { get; set; } + public string? ServiceName { get; set; } + public int ServicePort { get; set; } = 80; + public string PathPrefix { get; set; } = "/"; + public TlsMode TlsMode { get; set; } = TlsMode.ClusterIssuer; + public string? ClusterIssuerName { get; set; } + public string? TlsCertificate { get; set; } + public string? TlsPrivateKey { get; set; } + public string? GatewayName { get; set; } + public string? GatewayNamespace { get; set; } +} + +/// +/// Manages external routes — the simple abstraction over Gateway API HTTPRoutes. +/// Operators specify a hostname and TLS strategy; this service handles the rest. +/// +/// The flow is straightforward: +/// 1. Add a route to a component (hostname + TLS config) +/// 2. Generate the Kubernetes HTTPRoute YAML +/// 3. Apply it to the cluster (via kubectl or the K8s API) +/// +/// Routes are stored in the database so we can track what's exposed, +/// regenerate manifests, and tear down routes when components are uninstalled. +/// +public class ExternalRouteService(IDbContextFactory dbFactory) +{ + /// + /// Adds an external route to a component. The operator specifies the hostname + /// and TLS strategy — the service fills in defaults for anything not provided + /// (gateway name from the cluster's ingress controller, service name from the + /// component's release name, etc.). + /// + public async Task AddRouteAsync( + Guid componentId, ExternalRouteRequest request, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Load the component to fill in defaults. + + ClusterComponent component = await db.ClusterComponents + .Include(c => c.Cluster) + .ThenInclude(cl => cl.Components) + .FirstOrDefaultAsync(c => c.Id == componentId, ct) + ?? throw new InvalidOperationException("Component not found."); + + // Validate the hostname is not empty. + + if (string.IsNullOrWhiteSpace(request.Hostname)) + { + throw new InvalidOperationException("Hostname is required."); + } + + // Validate TLS configuration. + + if (request.TlsMode == TlsMode.ClusterIssuer && string.IsNullOrWhiteSpace(request.ClusterIssuerName)) + { + throw new InvalidOperationException( + "ClusterIssuer name is required when using automatic TLS."); + } + + if (request.TlsMode == TlsMode.Manual && string.IsNullOrWhiteSpace(request.TlsCertificate)) + { + throw new InvalidOperationException( + "TLS certificate is required when using manual TLS."); + } + + // Check for duplicate hostname on this cluster. + + List clusterComponentIds = component.Cluster.Components.Select(c => c.Id).ToList(); + bool duplicateHostname = await db.ExternalRoutes + .AnyAsync(r => clusterComponentIds.Contains(r.ComponentId) + && r.Hostname == request.Hostname.Trim().ToLowerInvariant(), ct); + + if (duplicateHostname) + { + throw new InvalidOperationException( + $"Hostname '{request.Hostname}' is already in use on this cluster."); + } + + // Resolve gateway details from the cluster's ingress controller if not provided. + + string gatewayName = request.GatewayName + ?? ResolveGatewayName(component.Cluster.Components); + string gatewayNamespace = request.GatewayNamespace + ?? ResolveGatewayNamespace(component.Cluster.Components); + + ExternalRoute route = new() + { + Id = Guid.NewGuid(), + ComponentId = componentId, + Hostname = request.Hostname.Trim().ToLowerInvariant(), + ServiceName = request.ServiceName ?? component.ReleaseName ?? component.Name, + ServicePort = request.ServicePort, + PathPrefix = string.IsNullOrWhiteSpace(request.PathPrefix) ? "/" : request.PathPrefix.Trim(), + TlsMode = request.TlsMode, + ClusterIssuerName = request.ClusterIssuerName?.Trim(), + TlsCertificate = request.TlsCertificate, + TlsPrivateKey = request.TlsPrivateKey, + GatewayName = gatewayName, + GatewayNamespace = gatewayNamespace + }; + + db.ExternalRoutes.Add(route); + await db.SaveChangesAsync(ct); + return route; + } + + /// + /// Gets all external routes for a component. + /// + public async Task> GetRoutesAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.ExternalRoutes + .Where(r => r.ComponentId == componentId) + .OrderBy(r => r.Hostname) + .ToListAsync(ct); + } + + /// + /// Deletes an external route. The caller should also remove the HTTPRoute + /// from the cluster (via kubectl delete or the K8s API). + /// + public async Task DeleteRouteAsync(Guid routeId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ExternalRoute route = await db.ExternalRoutes + .FirstOrDefaultAsync(r => r.Id == routeId, ct) + ?? throw new InvalidOperationException("Route not found."); + + db.ExternalRoutes.Remove(route); + await db.SaveChangesAsync(ct); + } + + /// + /// Generates the Gateway API HTTPRoute YAML manifest for a route. + /// This manifest can be applied to the cluster to expose the service. + /// + /// For ClusterIssuer TLS, it adds the cert-manager annotation. + /// For Manual TLS, it references a Kubernetes Secret (the caller must + /// create the Secret separately with the cert/key data). + /// + public async Task GenerateHttpRouteYamlAsync( + Guid routeId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ExternalRoute route = await db.ExternalRoutes + .Include(r => r.Component) + .FirstOrDefaultAsync(r => r.Id == routeId, ct) + ?? throw new InvalidOperationException("Route not found."); + + return GenerateHttpRouteYaml(route); + } + + /// + /// Generates HTTPRoute YAML from an in-memory route object. + /// Useful for previewing before saving. + /// + public static string GenerateHttpRouteYaml(ExternalRoute route) + { + string ns = route.Component?.Namespace ?? "default"; + string routeName = $"{route.ServiceName}-route"; + string secretName = $"{route.ServiceName}-tls"; + + // Build the annotations section based on TLS mode. + + string annotations = route.TlsMode == TlsMode.ClusterIssuer + ? $" cert-manager.io/cluster-issuer: \"{route.ClusterIssuerName}\"" + : $" entkube.io/tls-mode: \"manual\""; + + // Build the TLS section — for manual, reference a Secret that holds the cert. + + string tlsSection = route.TlsMode == TlsMode.Manual + ? $""" + tls: + certificateRefs: + - name: {secretName} + kind: Secret + """ + : ""; + + // Build path match rules. + + string pathMatch = route.PathPrefix != "/" + ? $""" + - matches: + - path: + type: PathPrefix + value: {route.PathPrefix} + backendRefs: + - name: {route.ServiceName} + port: {route.ServicePort} + """ + : $""" + - backendRefs: + - name: {route.ServiceName} + port: {route.ServicePort} + """; + + string yaml = $""" + apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: {routeName} + namespace: {ns} + annotations: + {annotations} + spec: + parentRefs: + - name: {route.GatewayName} + namespace: {route.GatewayNamespace} + {tlsSection} hostnames: + - {route.Hostname} + rules: + {pathMatch} + """; + + return yaml; + } + + /// + /// Generates a Kubernetes TLS Secret YAML for manual certificate mode. + /// The caller applies this to the cluster before or alongside the HTTPRoute. + /// + public static string GenerateTlsSecretYaml(ExternalRoute route) + { + if (route.TlsMode != TlsMode.Manual || string.IsNullOrWhiteSpace(route.TlsCertificate)) + { + return ""; + } + + string ns = route.Component?.Namespace ?? "default"; + string secretName = $"{route.ServiceName}-tls"; + + // Base64-encode the cert and key for Kubernetes Secret. + + string certBase64 = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(route.TlsCertificate)); + string keyBase64 = !string.IsNullOrWhiteSpace(route.TlsPrivateKey) + ? Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(route.TlsPrivateKey)) + : ""; + + string yaml = $""" + apiVersion: v1 + kind: Secret + metadata: + name: {secretName} + namespace: {ns} + type: kubernetes.io/tls + data: + tls.crt: {certBase64} + tls.key: {keyBase64} + """; + + return yaml; + } + + // ── Private helpers ── + + /// + /// Determines the Gateway resource name based on which ingress controller + /// is installed on the cluster. Traefik creates "traefik-gateway", Istio + /// uses its own naming convention. + /// + private static string ResolveGatewayName(IEnumerable components) + { + if (components.Any(c => string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase))) + { + return "traefik-gateway"; + } + + if (components.Any(c => string.Equals(c.Name, "istio", StringComparison.OrdinalIgnoreCase))) + { + return "istio-ingress"; + } + + return "default-gateway"; + } + + /// + /// Determines the Gateway namespace based on the installed ingress controller. + /// + private static string ResolveGatewayNamespace(IEnumerable components) + { + if (components.Any(c => string.Equals(c.Name, "traefik", StringComparison.OrdinalIgnoreCase))) + { + return "traefik"; + } + + if (components.Any(c => string.Equals(c.Name, "istio", StringComparison.OrdinalIgnoreCase))) + { + return "istio-system"; + } + + return "default"; + } +} diff --git a/src/EntKube.Web/Services/IKubernetesClientFactory.cs b/src/EntKube.Web/Services/IKubernetesClientFactory.cs new file mode 100644 index 0000000..90b9bcd --- /dev/null +++ b/src/EntKube.Web/Services/IKubernetesClientFactory.cs @@ -0,0 +1,41 @@ +namespace EntKube.Web.Services; + +/// +/// Abstraction over Kubernetes API operations that CnpgService needs. +/// Allows testing orchestration logic without real K8s clusters. +/// +/// Implementations use the k8s .NET client or kubectl under the hood, +/// applying manifests (CNPG Cluster CRDs, Backup CRDs, Secrets) and +/// executing SQL via kubectl exec against the primary pod. +/// +public interface IKubernetesClientFactory +{ + /// + /// Applies a YAML manifest to a Kubernetes cluster using the given kubeconfig. + /// Equivalent to "kubectl apply -f -" with the manifest piped in. + /// + Task ApplyManifestAsync(string manifest, string kubeconfig, CancellationToken ct = default); + + /// + /// Deletes a specific Kubernetes resource by kind/name/namespace. + /// Equivalent to "kubectl delete {kind} {name} -n {namespace}". + /// + Task DeleteManifestAsync(string kind, string name, string ns, string kubeconfig, CancellationToken ct = default); + + /// + /// Ensures a namespace exists, creating it if it doesn't. + /// Equivalent to "kubectl create namespace {ns} --dry-run=client -o yaml | kubectl apply -f -". + /// + Task EnsureNamespaceAsync(string ns, string kubeconfig, CancellationToken ct = default); + + /// + /// Gets the JSON output of a kubectl command. Used for querying cluster/pod status. + /// + Task GetJsonAsync(string resource, string ns, string kubeconfig, string labelSelector = "", CancellationToken ct = default); + + /// + /// Executes a SQL statement against the CNPG primary pod. + /// Uses kubectl exec to connect to the primary and run psql. + /// + Task ExecuteSqlAsync(string clusterName, string ns, string sql, string kubeconfig, CancellationToken ct = default); +} diff --git a/src/EntKube.Web/Services/KubeconfigParser.cs b/src/EntKube.Web/Services/KubeconfigParser.cs new file mode 100644 index 0000000..f30f4d9 --- /dev/null +++ b/src/EntKube.Web/Services/KubeconfigParser.cs @@ -0,0 +1,184 @@ +using YamlDotNet.RepresentationModel; + +namespace EntKube.Web.Services; + +/// +/// Represents a single context parsed from a kubeconfig file. +/// A context ties together a cluster (server URL) and user (credentials). +/// +public record KubeconfigContext(string Name, string ClusterServer, bool IsCurrent); + +/// +/// Parses kubeconfig YAML to extract available contexts and their associated +/// cluster server URLs. This lets users paste or upload a kubeconfig and pick +/// which context (cluster) to register. +/// +public static class KubeconfigParser +{ + /// + /// Parses a kubeconfig YAML string and returns all valid contexts. + /// Each context includes the resolved cluster server URL so the user + /// can make an informed choice. Invalid or unresolvable contexts are skipped. + /// + public static List ParseContexts(string kubeconfigYaml) + { + if (string.IsNullOrWhiteSpace(kubeconfigYaml)) + { + return []; + } + + try + { + YamlStream yaml = new(); + using (StringReader reader = new(kubeconfigYaml)) + { + yaml.Load(reader); + } + + if (yaml.Documents.Count == 0 || yaml.Documents[0].RootNode is not YamlMappingNode root) + { + return []; + } + + // Build a lookup of cluster name → server URL from the "clusters" array. + Dictionary clusterServers = BuildClusterServerMap(root); + + if (clusterServers.Count == 0) + { + return []; + } + + // Determine which context is marked as current. + string currentContext = GetScalarValue(root, "current-context") ?? ""; + + // Walk the "contexts" array and resolve each one against the cluster map. + YamlSequenceNode? contextsNode = GetSequenceNode(root, "contexts"); + + if (contextsNode is null) + { + return []; + } + + List results = []; + + foreach (YamlNode node in contextsNode) + { + if (node is not YamlMappingNode contextEntry) + { + continue; + } + + string? name = GetScalarValue(contextEntry, "name"); + + if (name is null) + { + continue; + } + + // The "context" sub-object holds the cluster reference. + YamlMappingNode? contextBody = GetMappingNode(contextEntry, "context"); + + if (contextBody is null) + { + continue; + } + + string? clusterRef = GetScalarValue(contextBody, "cluster"); + + if (clusterRef is null || !clusterServers.TryGetValue(clusterRef, out string? serverUrl)) + { + continue; + } + + bool isCurrent = string.Equals(name, currentContext, StringComparison.Ordinal); + results.Add(new KubeconfigContext(name, serverUrl, isCurrent)); + } + + return results; + } + catch + { + // Any YAML parsing failure — return empty rather than crash. + return []; + } + } + + /// + /// Builds a dictionary mapping cluster name to its server URL + /// from the "clusters" array in the kubeconfig root. + /// + private static Dictionary BuildClusterServerMap(YamlMappingNode root) + { + Dictionary map = new(StringComparer.Ordinal); + + YamlSequenceNode? clustersNode = GetSequenceNode(root, "clusters"); + + if (clustersNode is null) + { + return map; + } + + foreach (YamlNode node in clustersNode) + { + if (node is not YamlMappingNode clusterEntry) + { + continue; + } + + string? name = GetScalarValue(clusterEntry, "name"); + YamlMappingNode? clusterBody = GetMappingNode(clusterEntry, "cluster"); + + if (name is null || clusterBody is null) + { + continue; + } + + string? server = GetScalarValue(clusterBody, "server"); + + if (server is not null) + { + map[name] = server; + } + } + + return map; + } + + // --- YAML navigation helpers --- + + private static string? GetScalarValue(YamlMappingNode mapping, string key) + { + YamlScalarNode keyNode = new(key); + + if (mapping.Children.TryGetValue(keyNode, out YamlNode? value) && value is YamlScalarNode scalar) + { + return scalar.Value; + } + + return null; + } + + private static YamlSequenceNode? GetSequenceNode(YamlMappingNode mapping, string key) + { + YamlScalarNode keyNode = new(key); + + if (mapping.Children.TryGetValue(keyNode, out YamlNode? value) && value is YamlSequenceNode seq) + { + return seq; + } + + return null; + } + + private static YamlMappingNode? GetMappingNode(YamlMappingNode mapping, string key) + { + YamlScalarNode keyNode = new(key); + + if (mapping.Children.TryGetValue(keyNode, out YamlNode? value) && value is YamlMappingNode map) + { + return map; + } + + return null; + } +} diff --git a/src/EntKube.Web/Services/KubernetesClientFactory.cs b/src/EntKube.Web/Services/KubernetesClientFactory.cs new file mode 100644 index 0000000..df220eb --- /dev/null +++ b/src/EntKube.Web/Services/KubernetesClientFactory.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using System.Text; + +namespace EntKube.Web.Services; + +/// +/// Implements IKubernetesClientFactory using kubectl commands. +/// Writes manifests to temporary files and applies/deletes them via kubectl, +/// using the provided kubeconfig for authentication. +/// +public class KubernetesClientFactory : IKubernetesClientFactory +{ + /// + /// Applies a YAML manifest by writing it to a temp file and running kubectl apply. + /// The kubeconfig is written to a separate temp file for authentication. + /// + public async Task ApplyManifestAsync(string manifest, string kubeconfig, CancellationToken ct = default) + { + string kubeconfigPath = Path.GetTempFileName(); + string manifestPath = Path.GetTempFileName(); + + try + { + await File.WriteAllTextAsync(kubeconfigPath, kubeconfig, ct); + await File.WriteAllTextAsync(manifestPath, manifest, ct); + + string result = await RunKubectlAsync( + $"apply -f {manifestPath} --kubeconfig={kubeconfigPath}", ct); + } + finally + { + File.Delete(kubeconfigPath); + File.Delete(manifestPath); + } + } + + /// + /// Deletes a specific Kubernetes resource by kind, name, and namespace. + /// + public async Task DeleteManifestAsync( + string kind, string name, string ns, string kubeconfig, CancellationToken ct = default) + { + string kubeconfigPath = Path.GetTempFileName(); + + try + { + await File.WriteAllTextAsync(kubeconfigPath, kubeconfig, ct); + + await RunKubectlAsync( + $"delete {kind} {name} -n {ns} --kubeconfig={kubeconfigPath} --ignore-not-found", ct); + } + finally + { + File.Delete(kubeconfigPath); + } + } + + /// + /// Creates a namespace if it doesn't already exist. + /// + public async Task EnsureNamespaceAsync(string ns, string kubeconfig, CancellationToken ct = default) + { + string manifest = $"apiVersion: v1\nkind: Namespace\nmetadata:\n name: {ns}\n"; + await ApplyManifestAsync(manifest, kubeconfig, ct); + } + + /// + /// Gets JSON output from kubectl for a given resource type in a namespace. + /// Supports optional label selectors for filtering. + /// + public async Task GetJsonAsync( + string resource, string ns, string kubeconfig, string labelSelector = "", CancellationToken ct = default) + { + string kubeconfigPath = Path.GetTempFileName(); + + try + { + await File.WriteAllTextAsync(kubeconfigPath, kubeconfig, ct); + + string selector = string.IsNullOrEmpty(labelSelector) ? "" : $" -l {labelSelector}"; + return await RunKubectlAsync( + $"get {resource} -n {ns} --kubeconfig={kubeconfigPath}{selector} -o json", ct); + } + finally + { + File.Delete(kubeconfigPath); + } + } + + /// + /// Executes SQL on the CNPG primary pod via kubectl exec + psql. + /// The primary pod is identified by the CNPG naming convention: {cluster}-1. + /// + public async Task ExecuteSqlAsync( + string clusterName, string ns, string sql, string kubeconfig, CancellationToken ct = default) + { + string kubeconfigPath = Path.GetTempFileName(); + + try + { + await File.WriteAllTextAsync(kubeconfigPath, kubeconfig, ct); + + // CNPG primary pod follows the naming pattern: {cluster}-1 + string primaryPod = $"{clusterName}-1"; + + // Escape single quotes in SQL for shell safety. + string escapedSql = sql.Replace("'", "'\\''"); + + await RunKubectlAsync( + $"exec {primaryPod} -n {ns} --kubeconfig={kubeconfigPath} -- psql -U postgres -c '{escapedSql}'", ct); + } + finally + { + File.Delete(kubeconfigPath); + } + } + + private static async Task RunKubectlAsync(string arguments, CancellationToken ct) + { + using Process process = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "kubectl", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + + StringBuilder output = new(); + StringBuilder error = new(); + + Task outputTask = process.StandardOutput.ReadToEndAsync(ct); + Task errorTask = process.StandardError.ReadToEndAsync(ct); + + await process.WaitForExitAsync(ct); + + output.Append(await outputTask); + error.Append(await errorTask); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"kubectl failed (exit {process.ExitCode}): {error}"); + } + + return output.ToString(); + } +} diff --git a/src/EntKube.Web/Services/KubernetesOperationsService.cs b/src/EntKube.Web/Services/KubernetesOperationsService.cs new file mode 100644 index 0000000..05c0c66 --- /dev/null +++ b/src/EntKube.Web/Services/KubernetesOperationsService.cs @@ -0,0 +1,340 @@ +using EntKube.Web.Data; +using k8s; +using k8s.Models; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// A simple result type for Kubernetes operations. Operations can fail for +/// many reasons (no kubeconfig, cluster unreachable, pod not found), so we +/// use Result rather than exceptions for expected failures. +/// +public class KubernetesOperationResult +{ + public bool IsSuccess { get; init; } + public string? Error { get; init; } + + public static KubernetesOperationResult Success() => new() { IsSuccess = true }; + public static KubernetesOperationResult Failure(string error) => new() { IsSuccess = false, Error = error }; +} + +/// +/// Result type with a data payload for operations that return information +/// (e.g. pod lists, log content). +/// +public class KubernetesOperationResult +{ + public bool IsSuccess { get; init; } + public T? Data { get; init; } + public string? Error { get; init; } + + public static KubernetesOperationResult Success(T data) => new() { IsSuccess = true, Data = data }; + public static KubernetesOperationResult Failure(string error) => new() { IsSuccess = false, Error = error }; +} + +/// +/// A snapshot of a Kubernetes pod's state — name, status, container count, +/// restarts, age. Rendered in the portal's deployment detail view. +/// +public class PodInfo +{ + public required string Name { get; set; } + public required string Namespace { get; set; } + public required string Status { get; set; } + public int ReadyContainers { get; set; } + public int TotalContainers { get; set; } + public int Restarts { get; set; } + public DateTime? StartTime { get; set; } + public List Containers { get; set; } = []; +} + +/// +/// Per-container state within a pod. Lets the user pick which container +/// to view logs for in multi-container pods. +/// +public class ContainerInfo +{ + public required string Name { get; set; } + public required string Image { get; set; } + public bool Ready { get; set; } + public int RestartCount { get; set; } + public string? State { get; set; } +} + +/// +/// Performs live Kubernetes cluster operations for the customer portal — +/// listing pods, viewing logs, restarting deployments, deleting pods, and +/// scaling. Uses the stored kubeconfig from KubernetesCluster to authenticate. +/// +/// Each operation: +/// 1. Loads the deployment to find which cluster it targets +/// 2. Reads the cluster's kubeconfig +/// 3. Creates a K8s client and performs the operation +/// 4. Returns a Result so the UI can show success/failure gracefully +/// +public class KubernetesOperationsService(IDbContextFactory dbFactory) +{ + /// + /// Loads a deployment with its cluster attached, so we know where + /// to connect for Kubernetes API calls. + /// + public async Task GetDeploymentWithClusterAsync( + Guid deploymentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.AppDeployments + .Include(d => d.Cluster) + .Include(d => d.Environment) + .FirstOrDefaultAsync(d => d.Id == deploymentId, ct); + } + + /// + /// Lists all pods in the deployment's namespace, filtered by the deployment + /// name. Gives the portal a real-time view of what's actually running. + /// + public async Task>> GetPodsAsync( + Guid deploymentId, CancellationToken ct = default) + { + // First, load the deployment and its cluster so we know where to connect. + AppDeployment? deployment = await GetDeploymentWithClusterAsync(deploymentId, ct); + + if (deployment is null) + { + return KubernetesOperationResult>.Failure("Deployment not found."); + } + + // The cluster needs a kubeconfig to connect. + if (string.IsNullOrEmpty(deployment.Cluster.Kubeconfig)) + { + return KubernetesOperationResult>.Failure( + "Cluster has no kubeconfig configured. Upload a kubeconfig to enable cluster operations."); + } + + try + { + // Build a Kubernetes client from the stored kubeconfig. + using Kubernetes client = CreateClient(deployment.Cluster.Kubeconfig); + + // List pods in the deployment's namespace. + V1PodList podList = await client.CoreV1.ListNamespacedPodAsync( + deployment.Namespace, cancellationToken: ct); + + List pods = podList.Items.Select(pod => new PodInfo + { + Name = pod.Metadata.Name, + Namespace = pod.Metadata.NamespaceProperty ?? deployment.Namespace, + Status = pod.Status?.Phase ?? "Unknown", + ReadyContainers = pod.Status?.ContainerStatuses?.Count(cs => cs.Ready) ?? 0, + TotalContainers = pod.Spec?.Containers?.Count ?? 0, + Restarts = pod.Status?.ContainerStatuses?.Sum(cs => cs.RestartCount) ?? 0, + StartTime = pod.Status?.StartTime, + Containers = (pod.Status?.ContainerStatuses ?? []).Select(cs => new ContainerInfo + { + Name = cs.Name, + Image = cs.Image, + Ready = cs.Ready, + RestartCount = cs.RestartCount, + State = cs.State?.Running is not null ? "Running" + : cs.State?.Waiting is not null ? $"Waiting: {cs.State.Waiting.Reason}" + : cs.State?.Terminated is not null ? $"Terminated: {cs.State.Terminated.Reason}" + : "Unknown" + }).ToList() + }).ToList(); + + return KubernetesOperationResult>.Success(pods); + } + catch (Exception ex) + { + return KubernetesOperationResult>.Failure($"Failed to list pods: {ex.Message}"); + } + } + + /// + /// Retrieves log output from a specific pod. Optionally targets a + /// specific container in multi-container pods. Returns the last + /// tailLines lines to keep output manageable. + /// + public async Task> GetPodLogsAsync( + Guid deploymentId, + string podName, + string? containerName = null, + int tailLines = 500, + CancellationToken ct = default) + { + AppDeployment? deployment = await GetDeploymentWithClusterAsync(deploymentId, ct); + + if (deployment is null) + { + return KubernetesOperationResult.Failure("Deployment not found."); + } + + if (string.IsNullOrEmpty(deployment.Cluster.Kubeconfig)) + { + return KubernetesOperationResult.Failure( + "Cluster has no kubeconfig configured. Upload a kubeconfig to enable cluster operations."); + } + + try + { + using Kubernetes client = CreateClient(deployment.Cluster.Kubeconfig); + + // Fetch the last N lines of logs from the pod. + using Stream logStream = await client.CoreV1.ReadNamespacedPodLogAsync( + podName, + deployment.Namespace, + container: containerName, + tailLines: tailLines, + cancellationToken: ct); + + using StreamReader reader = new(logStream); + string logs = await reader.ReadToEndAsync(ct); + + return KubernetesOperationResult.Success(logs); + } + catch (Exception ex) + { + return KubernetesOperationResult.Failure($"Failed to fetch logs: {ex.Message}"); + } + } + + /// + /// Restarts a Kubernetes Deployment by patching its pod template annotation + /// with the current timestamp. This triggers a rolling restart — Kubernetes + /// creates new pods and terminates old ones gradually. + /// + public async Task RestartDeploymentAsync( + Guid deploymentId, + string k8sDeploymentName, + CancellationToken ct = default) + { + AppDeployment? deployment = await GetDeploymentWithClusterAsync(deploymentId, ct); + + if (deployment is null) + { + return KubernetesOperationResult.Failure("Deployment not found."); + } + + if (string.IsNullOrEmpty(deployment.Cluster.Kubeconfig)) + { + return KubernetesOperationResult.Failure( + "Cluster has no kubeconfig configured. Upload a kubeconfig to enable cluster operations."); + } + + try + { + using Kubernetes client = CreateClient(deployment.Cluster.Kubeconfig); + + // Patching the pod template annotation forces a rollout restart, + // identical to "kubectl rollout restart deployment/". + V1Patch patch = new( + "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":" + + $"{{\"kubectl.kubernetes.io/restartedAt\":\"{DateTime.UtcNow:O}\"}}" + + "}}}}", + V1Patch.PatchType.MergePatch); + + await client.AppsV1.PatchNamespacedDeploymentAsync( + patch, k8sDeploymentName, deployment.Namespace, cancellationToken: ct); + + return KubernetesOperationResult.Success(); + } + catch (Exception ex) + { + return KubernetesOperationResult.Failure($"Failed to restart deployment: {ex.Message}"); + } + } + + /// + /// Deletes a specific pod, which triggers Kubernetes to create a replacement + /// (assuming the pod is managed by a Deployment/ReplicaSet). This is the + /// equivalent of "kubectl delete pod ". + /// + public async Task DeletePodAsync( + Guid deploymentId, + string podName, + CancellationToken ct = default) + { + AppDeployment? deployment = await GetDeploymentWithClusterAsync(deploymentId, ct); + + if (deployment is null) + { + return KubernetesOperationResult.Failure("Deployment not found."); + } + + if (string.IsNullOrEmpty(deployment.Cluster.Kubeconfig)) + { + return KubernetesOperationResult.Failure( + "Cluster has no kubeconfig configured. Upload a kubeconfig to enable cluster operations."); + } + + try + { + using Kubernetes client = CreateClient(deployment.Cluster.Kubeconfig); + + await client.CoreV1.DeleteNamespacedPodAsync( + podName, deployment.Namespace, cancellationToken: ct); + + return KubernetesOperationResult.Success(); + } + catch (Exception ex) + { + return KubernetesOperationResult.Failure($"Failed to delete pod: {ex.Message}"); + } + } + + /// + /// Scales a Kubernetes Deployment to the specified number of replicas. + /// Useful for scaling up during traffic spikes or scaling down to save resources. + /// + public async Task ScaleDeploymentAsync( + Guid deploymentId, + string k8sDeploymentName, + int replicas, + CancellationToken ct = default) + { + AppDeployment? deployment = await GetDeploymentWithClusterAsync(deploymentId, ct); + + if (deployment is null) + { + return KubernetesOperationResult.Failure("Deployment not found."); + } + + if (string.IsNullOrEmpty(deployment.Cluster.Kubeconfig)) + { + return KubernetesOperationResult.Failure( + "Cluster has no kubeconfig configured. Upload a kubeconfig to enable cluster operations."); + } + + try + { + using Kubernetes client = CreateClient(deployment.Cluster.Kubeconfig); + + V1Patch patch = new( + $"{{\"spec\":{{\"replicas\":{replicas}}}}}", + V1Patch.PatchType.MergePatch); + + await client.AppsV1.PatchNamespacedDeploymentAsync( + patch, k8sDeploymentName, deployment.Namespace, cancellationToken: ct); + + return KubernetesOperationResult.Success(); + } + catch (Exception ex) + { + return KubernetesOperationResult.Failure($"Failed to scale deployment: {ex.Message}"); + } + } + + // ──────── Internal ──────── + + /// + /// Creates a Kubernetes client from a raw kubeconfig YAML string. + /// The kubeconfig is parsed in-memory — never written to disk. + /// + private static Kubernetes CreateClient(string kubeconfig) + { + using MemoryStream stream = new(System.Text.Encoding.UTF8.GetBytes(kubeconfig)); + KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream); + return new Kubernetes(config); + } +} diff --git a/src/EntKube.Web/Services/OpenStackS3Service.cs b/src/EntKube.Web/Services/OpenStackS3Service.cs new file mode 100644 index 0000000..68fd399 --- /dev/null +++ b/src/EntKube.Web/Services/OpenStackS3Service.cs @@ -0,0 +1,592 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.Runtime; +using EntKube.Web.Data; + +namespace EntKube.Web.Services; + +/// +/// Result of provisioning a Cleura S3 bucket via OpenStack. +/// Contains the S3 endpoint, access/secret keys, and bucket name. +/// +public class CleuraS3ProvisionResult +{ + public required string BucketName { get; set; } + public required string Endpoint { get; set; } + public required string AccessKey { get; set; } + public required string SecretKey { get; set; } + public required string Region { get; set; } +} + +/// +/// Information about an S3 bucket returned from ListBuckets. +/// +public class S3BucketInfo +{ + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } +} + +/// +/// A CORS rule for an S3 bucket. +/// +public class S3CorsRule +{ + public List AllowedOrigins { get; set; } = []; + public List AllowedMethods { get; set; } = []; + public List AllowedHeaders { get; set; } = []; + public int MaxAgeSeconds { get; set; } +} + +/// +/// Handles Cleura/City Cloud S3 bucket provisioning via OpenStack APIs: +/// 1. Authenticates against Keystone to obtain a scoped token +/// 2. Creates EC2 credentials (access/secret key pair) for S3 access +/// 3. Creates the bucket using the AWS S3 SDK against Cleura's S3 endpoint +/// +/// The flow: +/// - User has an OpenStackConnection with username/password stored in vault +/// - We authenticate via Keystone v3 password auth +/// - We create EC2 credentials scoped to the project +/// - We use those credentials to call the S3 API and create the bucket +/// - We store the credentials in the vault and return them +/// +public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory httpClientFactory) +{ + /// + /// Provisions a new S3 bucket on Cleura using the given OpenStack connection. + /// Authenticates via Keystone, creates EC2 credentials, then creates the bucket. + /// + public async Task CreateBucketAsync( + Guid tenantId, + OpenStackConnection connection, + string bucketName, + CancellationToken ct = default) + { + // Step 1: Retrieve the stored password from the vault. + + string? password = await vaultService.GetOpenStackSecretValueAsync( + tenantId, connection.Id, "OS_PASSWORD", ct); + + if (string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException( + "OpenStack password not found in vault. Re-enter the password in the connection settings."); + } + + // Step 2: Authenticate against Keystone to get a scoped token, user ID, and project ID. + + (string token, string userId, string projectId) = await AuthenticateKeystoneAsync(connection, password, ct); + + // Step 3: Create EC2 credentials for S3 access. + + (string accessKey, string secretKey) = await CreateEc2CredentialsAsync( + token, userId, projectId, connection.AuthUrl, ct); + + // Step 4: Determine the S3 endpoint from the region. + + string endpoint = GetS3Endpoint(connection.Region ?? "Kna1"); + + // Step 5: Create the bucket using the S3 API. + + await CreateS3BucketAsync(endpoint, accessKey, secretKey, bucketName, connection.Region ?? "Kna1", ct); + + // Step 6: Apply a default bucket policy that allows full CRUD on objects. + // Without this, the bucket exists but object operations may be denied. + + string defaultPolicy = BuildDefaultBucketPolicy(bucketName); + + using AmazonS3Client policyClient = CreateS3Client(endpoint, accessKey, secretKey, connection.Region ?? "Kna1"); + + await policyClient.PutBucketPolicyAsync(new PutBucketPolicyRequest + { + BucketName = bucketName, + Policy = defaultPolicy + }, ct); + + return new CleuraS3ProvisionResult + { + BucketName = bucketName, + Endpoint = endpoint, + AccessKey = accessKey, + SecretKey = secretKey, + Region = connection.Region ?? "Kna1" + }; + } + + /// + /// Authenticates against OpenStack Keystone v3 using password auth. + /// Returns the scoped auth token and the user ID (needed for EC2 credential creation). + /// + private async Task<(string Token, string UserId, string ProjectId)> AuthenticateKeystoneAsync( + OpenStackConnection connection, string password, CancellationToken ct) + { + // Build the Keystone v3 auth request body. + // We request a project-scoped token so the EC2 credentials inherit the right scope. + + object authBody = new + { + auth = new + { + identity = new + { + methods = new[] { "password" }, + password = new + { + user = new + { + name = connection.Username, + password, + domain = new { name = connection.UserDomainName ?? "Default" } + } + } + }, + scope = new + { + project = connection.ProjectId is not null + ? (object)new { id = connection.ProjectId } + : new { name = connection.ProjectName, domain = new { name = connection.ProjectDomainName ?? "Default" } } + } + } + }; + + string json = JsonSerializer.Serialize(authBody); + + using HttpClient client = httpClientFactory.CreateClient(); + string authUrl = connection.AuthUrl.TrimEnd('/'); + + // Ensure the URL includes the Keystone v3 path segment. + // Users may store just the base (e.g. https://identity.example.com:5000) + // or the full path (https://identity.example.com:5000/v3). + + if (!authUrl.EndsWith("/v3", StringComparison.OrdinalIgnoreCase)) + { + authUrl += "/v3"; + } + + using HttpRequestMessage request = new(HttpMethod.Post, $"{authUrl}/auth/tokens") + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + using HttpResponseMessage response = await client.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + { + string errorBody = await response.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Keystone authentication failed ({response.StatusCode}): {errorBody}"); + } + + // The token is returned in the X-Subject-Token header. + + string token = response.Headers.GetValues("X-Subject-Token").First(); + + // Extract the user ID and project ID from the response body. + // The project ID here is always a UUID, even if we authenticated by project name. + + using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + JsonElement tokenElement = doc.RootElement.GetProperty("token"); + + string userId = tokenElement + .GetProperty("user") + .GetProperty("id") + .GetString()!; + + string projectId = tokenElement + .GetProperty("project") + .GetProperty("id") + .GetString()!; + + return (token, userId, projectId); + } + + /// + /// Creates EC2 credentials via the Keystone v3 credentials API. + /// EC2 credentials provide an access/secret key pair that can be used + /// with any S3-compatible client against the OpenStack Object Store. + /// + private async Task<(string AccessKey, string SecretKey)> CreateEc2CredentialsAsync( + string token, string userId, string projectId, string rawAuthUrl, CancellationToken ct) + { + // The EC2 credentials API lives at /v3/users/{userId}/credentials/OS-EC2 + + string authUrl = rawAuthUrl.TrimEnd('/'); + + if (!authUrl.EndsWith("/v3", StringComparison.OrdinalIgnoreCase)) + { + authUrl += "/v3"; + } + + string url = $"{authUrl}/users/{userId}/credentials/OS-EC2"; + + object body = new + { + tenant_id = projectId + }; + + string json = JsonSerializer.Serialize(body); + + using HttpClient client = httpClientFactory.CreateClient(); + + using HttpRequestMessage request = new(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("X-Auth-Token", token); + + using HttpResponseMessage response = await client.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + { + string errorBody = await response.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Failed to create EC2 credentials ({response.StatusCode}): {errorBody}"); + } + + using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + JsonElement credential = doc.RootElement.GetProperty("credential"); + + string accessKey = credential.GetProperty("access").GetString()!; + string secretKey = credential.GetProperty("secret").GetString()!; + + return (accessKey, secretKey); + } + + /// + /// Creates an S3 bucket using the AWS SDK against Cleura's S3-compatible endpoint. + /// + private static async Task CreateS3BucketAsync( + string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + PutBucketRequest putRequest = new() + { + BucketName = bucketName + }; + + await s3Client.PutBucketAsync(putRequest, ct); + } + + /// + /// Maps a Cleura region code to the S3 endpoint URL. + /// Cleura exposes S3-compatible object storage at region-specific endpoints. + /// + private static string GetS3Endpoint(string region) + { + // Cleura/City Cloud S3 endpoint pattern (standard HTTPS port). + // Known regions: Kna1, Sto2, Fra1, Lon1, etc. + + return $"https://s3-{region.ToLowerInvariant()}.citycloud.com"; + } + + /// + /// Builds a default S3 bucket policy that grants the bucket owner full CRUD + /// access to all objects. This is applied immediately after bucket creation + /// so the EC2 credentials can read, write, list, and delete objects. + /// + private static string BuildDefaultBucketPolicy(string bucketName) + { + // Standard S3 bucket policy: allow all object operations for the bucket owner. + // The "*" principal with the specific bucket ARN scopes this to the + // credentials that created the bucket (EC2 credentials are project-scoped). + + return $$""" + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowFullObjectAccess", + "Effect": "Allow", + "Principal": "*", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:GetBucketLocation" + ], + "Resource": [ + "arn:aws:s3:::{{bucketName}}", + "arn:aws:s3:::{{bucketName}}/*" + ] + } + ] + } + """; + } + + // ──────── Bucket Management ──────── + + /// + /// Lists all buckets accessible with the stored EC2 credentials for a given storage link. + /// This allows the user to see what buckets exist under their project + /// without needing to open a separate tool. + /// + public async Task> ListBucketsAsync( + string endpoint, string accessKey, string secretKey, string region, CancellationToken ct = default) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + ListBucketsResponse response = await s3Client.ListBucketsAsync(ct); + + return response.Buckets + .Select(b => new S3BucketInfo { Name = b.BucketName, CreatedAt = b.CreationDate.GetValueOrDefault() }) + .ToList(); + } + + /// + /// Deletes an S3 bucket. The bucket must be empty before deletion. + /// This removes the bucket from the object store — it does NOT remove + /// the StorageLink record (the caller handles that separately). + /// + public async Task DeleteBucketAsync( + string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + // Empty the bucket first — S3 won't delete non-empty buckets. + // We page through all objects and delete them in batches of up to 1000. + + string? continuationToken = null; + + do + { + ListObjectsV2Request listRequest = new() + { + BucketName = bucketName, + MaxKeys = 1000, + ContinuationToken = continuationToken + }; + + ListObjectsV2Response listResponse = await s3Client.ListObjectsV2Async(listRequest, ct); + + if (listResponse.S3Objects.Count > 0) + { + DeleteObjectsRequest deleteObjectsRequest = new() + { + BucketName = bucketName, + Objects = listResponse.S3Objects + .Select(o => new KeyVersion { Key = o.Key }) + .ToList() + }; + + await s3Client.DeleteObjectsAsync(deleteObjectsRequest, ct); + } + + continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null; + } + while (continuationToken is not null); + + // Remove the bucket policy before deletion. Ceph RADOS Gateway (used by Cleura) + // may reject DeleteBucket if a policy is still attached. + + try + { + await s3Client.DeleteBucketPolicyAsync(new DeleteBucketPolicyRequest { BucketName = bucketName }, ct); + } + catch (AmazonS3Exception) + { + // No policy to remove — that's fine, proceed with deletion. + } + + DeleteBucketRequest deleteRequest = new() { BucketName = bucketName }; + await s3Client.DeleteBucketAsync(deleteRequest, ct); + } + + /// + /// Gets the CORS configuration for a bucket. + /// Returns null if no CORS configuration is set. + /// + public async Task?> GetBucketCorsAsync( + string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + try + { + GetCORSConfigurationRequest request = new() { BucketName = bucketName }; + GetCORSConfigurationResponse response = await s3Client.GetCORSConfigurationAsync(request, ct); + + return response.Configuration.Rules + .Select(r => new S3CorsRule + { + AllowedOrigins = r.AllowedOrigins, + AllowedMethods = r.AllowedMethods, + AllowedHeaders = r.AllowedHeaders, + MaxAgeSeconds = r.MaxAgeSeconds ?? 0 + }) + .ToList(); + } + catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchCORSConfiguration") + { + return null; + } + } + + /// + /// Sets the CORS configuration for a bucket. Replaces any existing rules. + /// Pass an empty list to effectively disable CORS (removes the configuration). + /// + public async Task SetBucketCorsAsync( + string endpoint, string accessKey, string secretKey, string bucketName, string region, + List rules, CancellationToken ct = default) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + if (rules.Count == 0) + { + // Remove CORS configuration entirely. + + DeleteCORSConfigurationRequest deleteRequest = new() { BucketName = bucketName }; + await s3Client.DeleteCORSConfigurationAsync(deleteRequest, ct); + return; + } + + CORSConfiguration corsConfig = new() + { + Rules = rules.Select(r => new CORSRule + { + AllowedOrigins = r.AllowedOrigins, + AllowedMethods = r.AllowedMethods, + AllowedHeaders = r.AllowedHeaders, + MaxAgeSeconds = r.MaxAgeSeconds + }).ToList() + }; + + PutCORSConfigurationRequest putRequest = new() + { + BucketName = bucketName, + Configuration = corsConfig + }; + + await s3Client.PutCORSConfigurationAsync(putRequest, ct); + } + + /// + /// Gets the bucket policy as a JSON string. Returns null if no policy is set. + /// + public async Task GetBucketPolicyAsync( + string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + try + { + GetBucketPolicyRequest request = new() { BucketName = bucketName }; + GetBucketPolicyResponse response = await s3Client.GetBucketPolicyAsync(request, ct); + return response.Policy; + } + catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucketPolicy") + { + return null; + } + } + + /// + /// Sets the bucket policy from a JSON string. Pass null to remove the policy. + /// The policy JSON must be a valid S3 bucket policy document. + /// + public async Task SetBucketPolicyAsync( + string endpoint, string accessKey, string secretKey, string bucketName, string region, + string? policyJson, CancellationToken ct = default) + { + using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); + + if (policyJson is null) + { + DeleteBucketPolicyRequest deleteRequest = new() { BucketName = bucketName }; + await s3Client.DeleteBucketPolicyAsync(deleteRequest, ct); + return; + } + + PutBucketPolicyRequest putRequest = new() + { + BucketName = bucketName, + Policy = policyJson + }; + + await s3Client.PutBucketPolicyAsync(putRequest, ct); + } + + // ──────── EC2 Credential Rotation ──────── + + /// + /// Rotates the EC2 credentials for a Cleura S3 storage link. + /// This creates new credentials via Keystone, updates the vault, + /// and deletes the old credentials. The bucket itself is not affected — + /// only the access/secret key pair changes. + /// + /// Flow: + /// 1. Authenticate with Keystone using the stored password + /// 2. Create new EC2 credentials + /// 3. Verify the new credentials work (list bucket) + /// 4. Return the new credentials (caller updates vault) + /// + public async Task<(string AccessKey, string SecretKey)> RotateCredentialsAsync( + Guid tenantId, OpenStackConnection connection, string bucketName, CancellationToken ct = default) + { + // Retrieve the OpenStack password from vault. + + string? password = await vaultService.GetOpenStackSecretValueAsync( + tenantId, connection.Id, "OS_PASSWORD", ct); + + if (string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException( + "OpenStack password not found in vault. Re-enter the password in the connection settings."); + } + + // Authenticate and create fresh EC2 credentials. + + (string token, string userId, string projectId) = await AuthenticateKeystoneAsync(connection, password, ct); + (string newAccessKey, string newSecretKey) = await CreateEc2CredentialsAsync(token, userId, projectId, connection.AuthUrl, ct); + + // Verify the new credentials work by listing the bucket. + + string endpoint = GetS3Endpoint(connection.Region ?? "Kna1"); + + using AmazonS3Client s3Client = CreateS3Client(endpoint, newAccessKey, newSecretKey, connection.Region ?? "Kna1"); + + try + { + ListObjectsV2Request listRequest = new() { BucketName = bucketName, MaxKeys = 1 }; + await s3Client.ListObjectsV2Async(listRequest, ct); + } + catch (AmazonS3Exception ex) + { + throw new InvalidOperationException( + $"New credentials failed verification against bucket '{bucketName}': {ex.Message}", ex); + } + + return (newAccessKey, newSecretKey); + } + + // ──────── Helpers ──────── + + /// + /// Creates a configured AmazonS3Client for Cleura's S3-compatible endpoint. + /// + private static AmazonS3Client CreateS3Client(string endpoint, string accessKey, string secretKey, string region) + { + AmazonS3Config config = new() + { + ServiceURL = endpoint, + ForcePathStyle = true, + AuthenticationRegion = region, + // Disable automatic region detection — we know the endpoint. + // Without this, the SDK may hang trying to resolve the bucket region. + UseHttp = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase), + Timeout = TimeSpan.FromSeconds(30), + MaxErrorRetry = 1 + }; + + BasicAWSCredentials credentials = new(accessKey, secretKey); + return new AmazonS3Client(credentials, config); + } +} diff --git a/src/EntKube.Web/Services/PrometheusService.cs b/src/EntKube.Web/Services/PrometheusService.cs new file mode 100644 index 0000000..bda3ebb --- /dev/null +++ b/src/EntKube.Web/Services/PrometheusService.cs @@ -0,0 +1,760 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using EntKube.Web.Data; +using k8s; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Configuration extracted from a kube-prometheus-stack component that tells +/// us where to find the Prometheus and Alertmanager services inside the cluster. +/// +public class PrometheusConfig +{ + public string Namespace { get; set; } = "monitoring"; + public string ServiceName { get; set; } = "prometheus-kube-prometheus-prometheus"; + public int ServicePort { get; set; } = 9090; + public string AlertmanagerServiceName { get; set; } = "prometheus-kube-prometheus-alertmanager"; + public int AlertmanagerServicePort { get; set; } = 9093; +} + +/// +/// A single instant-query result from Prometheus — one metric with its labels and value. +/// +public class PrometheusMetricResult +{ + public Dictionary Labels { get; set; } = new(); + public double Value { get; set; } + public DateTime Timestamp { get; set; } +} + +/// +/// A time-series result from a Prometheus range query — a set of data points over time. +/// +public class PrometheusTimeSeries +{ + public Dictionary Labels { get; set; } = new(); + public List DataPoints { get; set; } = []; +} + +/// +/// A single data point in a time series — a timestamp/value pair. +/// +public class TimeSeriesDataPoint +{ + public DateTime Timestamp { get; set; } + public double Value { get; set; } +} + +/// +/// Summary of cluster health metrics retrieved from Prometheus. +/// +public class ClusterHealthSummary +{ + public double CpuUsagePercent { get; set; } + public double MemoryUsagePercent { get; set; } + public int TotalNodes { get; set; } + public int ReadyNodes { get; set; } + public int TotalPods { get; set; } + public int RunningPods { get; set; } + public int PendingPods { get; set; } + public int FailedPods { get; set; } + public double DiskUsagePercent { get; set; } + public List Nodes { get; set; } = []; + public DateTime QueriedAt { get; set; } = DateTime.UtcNow; +} + +/// +/// Per-node health information within a cluster. +/// +public class NodeHealthInfo +{ + public string Name { get; set; } = ""; + public bool Ready { get; set; } + public double CpuUsagePercent { get; set; } + public double MemoryUsagePercent { get; set; } +} + +/// +/// An alert from Alertmanager. +/// +public class AlertInfo +{ + public string Name { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Summary { get; set; } = ""; + public string Description { get; set; } = ""; + public string State { get; set; } = ""; + public string Fingerprint { get; set; } = ""; + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + public Dictionary Labels { get; set; } = new(); +} + +/// +/// A silence from Alertmanager. +/// +public class SilenceInfo +{ + public string Id { get; set; } = ""; + public string State { get; set; } = ""; + public string Comment { get; set; } = ""; + public string CreatedBy { get; set; } = ""; + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + public List Matchers { get; set; } = []; +} + +/// +/// A matcher used in an Alertmanager silence to select which alerts to suppress. +/// +public class SilenceMatcher +{ + public string Name { get; set; } = ""; + public string Value { get; set; } = ""; + public bool IsRegex { get; set; } + public bool IsEqual { get; set; } = true; +} + +/// +/// Connects to a kube-prometheus-stack running in a cluster and retrieves +/// health metrics, time-series data, alerts, and silences via port-forwarded +/// or direct service access through the Kubernetes API proxy. +/// +/// The service locates the Prometheus component on the cluster, builds a +/// Kubernetes client from the stored kubeconfig, and queries the Prometheus +/// HTTP API via the K8s API server proxy endpoint. +/// +public class PrometheusService(IDbContextFactory dbFactory) +{ + // ──────── Public API ──────── + + /// + /// Retrieves a health summary for the cluster by querying key Prometheus + /// metrics (CPU, memory, nodes, pods, disk). Returns a failure result if + /// the cluster isn't found, has no Prometheus component, or lacks kubeconfig. + /// + public async Task> GetClusterHealthAsync( + Guid clusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Load the cluster with its components to find the prometheus instance. + + KubernetesCluster? cluster = await db.KubernetesClusters + .Include(c => c.Components) + .FirstOrDefaultAsync(c => c.Id == clusterId, ct); + + if (cluster is null) + { + return KubernetesOperationResult.Failure("Cluster not found."); + } + + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + return KubernetesOperationResult.Failure( + "No kubeconfig configured for this cluster."); + } + + // Find the prometheus component on this cluster. + + ClusterComponent? prometheusComponent = cluster.Components.FirstOrDefault(c => + c.Name.Contains("prometheus", StringComparison.OrdinalIgnoreCase)); + + if (prometheusComponent is null) + { + return KubernetesOperationResult.Failure( + "No prometheus component found on this cluster."); + } + + PrometheusConfig config = GetPrometheusConfig(prometheusComponent) ?? new PrometheusConfig(); + + try + { + Kubernetes client = CreateClient(cluster.Kubeconfig); + string baseUrl = $"/api/v1/namespaces/{config.Namespace}/services/{config.ServiceName}:{config.ServicePort}/proxy"; + + // Query key health metrics in parallel. + + Task cpuTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=100 - (avg(rate(node_cpu_seconds_total{{mode=\"idle\"}}[5m])) * 100)", ct); + Task memTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=100 - (avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100)", ct); + Task nodesTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=count(kube_node_info)", ct); + Task readyNodesTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=count(kube_node_status_condition{{condition=\"Ready\",status=\"true\"}})", ct); + Task podsTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=count(kube_pod_info)", ct); + Task runningPodsTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=sum(kube_pod_status_phase{{phase=\"Running\"}})", ct); + Task diskTask = ProxyGetAsync(client, $"{baseUrl}/api/v1/query?query=100 - (avg(node_filesystem_avail_bytes{{mountpoint=\"/\"}} / node_filesystem_size_bytes{{mountpoint=\"/\"}}) * 100)", ct); + + await Task.WhenAll(cpuTask, memTask, nodesTask, readyNodesTask, podsTask, runningPodsTask, diskTask); + + ClusterHealthSummary health = new() + { + CpuUsagePercent = ExtractScalarValue(await cpuTask), + MemoryUsagePercent = ExtractScalarValue(await memTask), + TotalNodes = (int)ExtractScalarValue(await nodesTask), + ReadyNodes = (int)ExtractScalarValue(await readyNodesTask), + TotalPods = (int)ExtractScalarValue(await podsTask), + RunningPods = (int)ExtractScalarValue(await runningPodsTask), + DiskUsagePercent = ExtractScalarValue(await diskTask), + QueriedAt = DateTime.UtcNow + }; + + return KubernetesOperationResult.Success(health); + } + catch (Exception ex) + { + return KubernetesOperationResult.Failure( + $"Failed to query Prometheus: {ex.Message}"); + } + } + + /// + /// Queries a Prometheus range query over the given duration and returns time-series data. + /// + public async Task>> GetMetricRangeAsync( + Guid clusterId, string query, TimeSpan duration, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KubernetesCluster? cluster = await db.KubernetesClusters + .Include(c => c.Components) + .FirstOrDefaultAsync(c => c.Id == clusterId, ct); + + if (cluster is null) + { + return KubernetesOperationResult>.Failure("Cluster not found."); + } + + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + return KubernetesOperationResult>.Failure("No kubeconfig."); + } + + ClusterComponent? prometheusComponent = cluster.Components.FirstOrDefault(c => + c.Name.Contains("prometheus", StringComparison.OrdinalIgnoreCase)); + + if (prometheusComponent is null) + { + return KubernetesOperationResult>.Failure("No prometheus component."); + } + + PrometheusConfig config = GetPrometheusConfig(prometheusComponent) ?? new PrometheusConfig(); + + try + { + Kubernetes client = CreateClient(cluster.Kubeconfig); + string baseUrl = $"/api/v1/namespaces/{config.Namespace}/services/{config.ServiceName}:{config.ServicePort}/proxy"; + + long end = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + long start = end - (long)duration.TotalSeconds; + int step = Math.Max(15, (int)(duration.TotalSeconds / 100)); + + string encodedQuery = Uri.EscapeDataString(query); + string url = $"{baseUrl}/api/v1/query_range?query={encodedQuery}&start={start}&end={end}&step={step}"; + + string json = await ProxyGetAsync(client, url, ct); + List series = ParseRangeQueryResult(json); + + return KubernetesOperationResult>.Success(series); + } + catch (Exception ex) + { + return KubernetesOperationResult>.Failure( + $"Range query failed: {ex.Message}"); + } + } + + /// + /// Retrieves active alerts from Alertmanager. + /// + public async Task>> GetAlertsAsync( + Guid clusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KubernetesCluster? cluster = await db.KubernetesClusters + .Include(c => c.Components) + .FirstOrDefaultAsync(c => c.Id == clusterId, ct); + + if (cluster is null) + { + return KubernetesOperationResult>.Failure("Cluster not found."); + } + + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + return KubernetesOperationResult>.Failure("No kubeconfig."); + } + + ClusterComponent? prometheusComponent = cluster.Components.FirstOrDefault(c => + c.Name.Contains("prometheus", StringComparison.OrdinalIgnoreCase)); + + if (prometheusComponent is null) + { + return KubernetesOperationResult>.Failure("No prometheus component."); + } + + PrometheusConfig config = GetPrometheusConfig(prometheusComponent) ?? new PrometheusConfig(); + + try + { + Kubernetes client = CreateClient(cluster.Kubeconfig); + string baseUrl = $"/api/v1/namespaces/{config.Namespace}/services/{config.AlertmanagerServiceName}:{config.AlertmanagerServicePort}/proxy"; + + string json = await ProxyGetAsync(client, $"{baseUrl}/api/v2/alerts", ct); + List alerts = ParseAlertmanagerAlerts(json); + + return KubernetesOperationResult>.Success(alerts); + } + catch (Exception ex) + { + return KubernetesOperationResult>.Failure( + $"Failed to query Alertmanager: {ex.Message}"); + } + } + + /// + /// Retrieves silences from Alertmanager. + /// + public async Task>> GetSilencesAsync( + Guid clusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KubernetesCluster? cluster = await db.KubernetesClusters + .Include(c => c.Components) + .FirstOrDefaultAsync(c => c.Id == clusterId, ct); + + if (cluster is null) + { + return KubernetesOperationResult>.Failure("Cluster not found."); + } + + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + return KubernetesOperationResult>.Failure("No kubeconfig."); + } + + ClusterComponent? prometheusComponent = cluster.Components.FirstOrDefault(c => + c.Name.Contains("prometheus", StringComparison.OrdinalIgnoreCase)); + + if (prometheusComponent is null) + { + return KubernetesOperationResult>.Failure("No prometheus component."); + } + + PrometheusConfig config = GetPrometheusConfig(prometheusComponent) ?? new PrometheusConfig(); + + try + { + Kubernetes client = CreateClient(cluster.Kubeconfig); + string baseUrl = $"/api/v1/namespaces/{config.Namespace}/services/{config.AlertmanagerServiceName}:{config.AlertmanagerServicePort}/proxy"; + + string json = await ProxyGetAsync(client, $"{baseUrl}/api/v2/silences", ct); + List silences = ParseAlertmanagerSilences(json); + + return KubernetesOperationResult>.Success(silences); + } + catch (Exception ex) + { + return KubernetesOperationResult>.Failure( + $"Failed to query Alertmanager silences: {ex.Message}"); + } + } + + /// + /// Creates a new silence in Alertmanager. + /// + public async Task CreateSilenceAsync( + Guid clusterId, string comment, string createdBy, TimeSpan duration, + List matchers, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KubernetesCluster? cluster = await db.KubernetesClusters + .Include(c => c.Components) + .FirstOrDefaultAsync(c => c.Id == clusterId, ct); + + if (cluster is null) + { + return KubernetesOperationResult.Failure("Cluster not found."); + } + + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + return KubernetesOperationResult.Failure("No kubeconfig."); + } + + ClusterComponent? prometheusComponent = cluster.Components.FirstOrDefault(c => + c.Name.Contains("prometheus", StringComparison.OrdinalIgnoreCase)); + + if (prometheusComponent is null) + { + return KubernetesOperationResult.Failure("No prometheus component."); + } + + PrometheusConfig config = GetPrometheusConfig(prometheusComponent) ?? new PrometheusConfig(); + + try + { + Kubernetes client = CreateClient(cluster.Kubeconfig); + string baseUrl = $"/api/v1/namespaces/{config.Namespace}/services/{config.AlertmanagerServiceName}:{config.AlertmanagerServicePort}/proxy"; + + object silencePayload = new + { + matchers = matchers.Select(m => new + { + name = m.Name, + value = m.Value, + isRegex = m.IsRegex, + isEqual = m.IsEqual + }).ToArray(), + startsAt = DateTime.UtcNow.ToString("o"), + endsAt = DateTime.UtcNow.Add(duration).ToString("o"), + createdBy, + comment + }; + + string body = JsonSerializer.Serialize(silencePayload); + await ProxyPostAsync(client, $"{baseUrl}/api/v2/silences", body, ct); + + return KubernetesOperationResult.Success(); + } + catch (Exception ex) + { + return KubernetesOperationResult.Failure($"Failed to create silence: {ex.Message}"); + } + } + + // ──────── Static Parsing Methods ──────── + + /// + /// Parses a Prometheus instant query response (vector or scalar) into metric results. + /// + public static List ParseInstantQueryResult(string json) + { + List results = []; + + try + { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + if (root.GetProperty("status").GetString() != "success") + { + return results; + } + + JsonElement data = root.GetProperty("data"); + string resultType = data.GetProperty("resultType").GetString() ?? ""; + + if (resultType == "vector") + { + foreach (JsonElement item in data.GetProperty("result").EnumerateArray()) + { + PrometheusMetricResult metric = new(); + + if (item.TryGetProperty("metric", out JsonElement metricLabels)) + { + foreach (JsonProperty prop in metricLabels.EnumerateObject()) + { + metric.Labels[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + JsonElement value = item.GetProperty("value"); + metric.Timestamp = DateTimeOffset.FromUnixTimeSeconds((long)value[0].GetDouble()).UtcDateTime; + metric.Value = double.Parse(value[1].GetString() ?? "0", CultureInfo.InvariantCulture); + + results.Add(metric); + } + } + else if (resultType == "scalar") + { + JsonElement result = data.GetProperty("result"); + PrometheusMetricResult metric = new() + { + Timestamp = DateTimeOffset.FromUnixTimeSeconds((long)result[0].GetDouble()).UtcDateTime, + Value = double.Parse(result[1].GetString() ?? "0", CultureInfo.InvariantCulture) + }; + results.Add(metric); + } + } + catch + { + // Graceful degradation — return empty on parse failure. + } + + return results; + } + + /// + /// Parses a Prometheus range query response (matrix) into time-series data. + /// + public static List ParseRangeQueryResult(string json) + { + List results = []; + + try + { + using JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + if (root.GetProperty("status").GetString() != "success") + { + return results; + } + + JsonElement data = root.GetProperty("data"); + + if (data.GetProperty("resultType").GetString() != "matrix") + { + return results; + } + + foreach (JsonElement item in data.GetProperty("result").EnumerateArray()) + { + PrometheusTimeSeries series = new(); + + if (item.TryGetProperty("metric", out JsonElement metricLabels)) + { + foreach (JsonProperty prop in metricLabels.EnumerateObject()) + { + series.Labels[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + if (item.TryGetProperty("values", out JsonElement values)) + { + foreach (JsonElement point in values.EnumerateArray()) + { + TimeSeriesDataPoint dp = new() + { + Timestamp = DateTimeOffset.FromUnixTimeSeconds((long)point[0].GetDouble()).UtcDateTime, + Value = double.Parse(point[1].GetString() ?? "0", CultureInfo.InvariantCulture) + }; + series.DataPoints.Add(dp); + } + } + + results.Add(series); + } + } + catch + { + // Graceful degradation. + } + + return results; + } + + /// + /// Parses an Alertmanager /api/v2/alerts JSON response into AlertInfo objects. + /// + public static List ParseAlertmanagerAlerts(string json) + { + List results = []; + + try + { + using JsonDocument doc = JsonDocument.Parse(json); + + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return results; + } + + foreach (JsonElement alert in doc.RootElement.EnumerateArray()) + { + AlertInfo info = new(); + + if (alert.TryGetProperty("labels", out JsonElement labels)) + { + foreach (JsonProperty prop in labels.EnumerateObject()) + { + info.Labels[prop.Name] = prop.Value.GetString() ?? ""; + } + + info.Name = info.Labels.GetValueOrDefault("alertname") ?? ""; + info.Severity = info.Labels.GetValueOrDefault("severity") ?? ""; + } + + if (alert.TryGetProperty("annotations", out JsonElement annotations)) + { + info.Summary = annotations.TryGetProperty("summary", out JsonElement s) + ? s.GetString() ?? "" : ""; + info.Description = annotations.TryGetProperty("description", out JsonElement d) + ? d.GetString() ?? "" : ""; + } + + if (alert.TryGetProperty("startsAt", out JsonElement startsAt)) + { + DateTime.TryParse(startsAt.GetString(), out DateTime parsed); + info.StartsAt = parsed; + } + + if (alert.TryGetProperty("endsAt", out JsonElement endsAt)) + { + DateTime.TryParse(endsAt.GetString(), out DateTime parsed); + info.EndsAt = parsed; + } + + if (alert.TryGetProperty("status", out JsonElement status) + && status.TryGetProperty("state", out JsonElement state)) + { + info.State = state.GetString() ?? ""; + } + + if (alert.TryGetProperty("fingerprint", out JsonElement fp)) + { + info.Fingerprint = fp.GetString() ?? ""; + } + + results.Add(info); + } + } + catch + { + // Graceful degradation. + } + + return results; + } + + /// + /// Parses an Alertmanager /api/v2/silences JSON response into SilenceInfo objects. + /// + public static List ParseAlertmanagerSilences(string json) + { + List results = []; + + try + { + using JsonDocument doc = JsonDocument.Parse(json); + + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return results; + } + + foreach (JsonElement silence in doc.RootElement.EnumerateArray()) + { + SilenceInfo info = new(); + + if (silence.TryGetProperty("id", out JsonElement id)) + { + info.Id = id.GetString() ?? ""; + } + + if (silence.TryGetProperty("status", out JsonElement status) + && status.TryGetProperty("state", out JsonElement state)) + { + info.State = state.GetString() ?? ""; + } + + if (silence.TryGetProperty("comment", out JsonElement comment)) + { + info.Comment = comment.GetString() ?? ""; + } + + if (silence.TryGetProperty("createdBy", out JsonElement createdBy)) + { + info.CreatedBy = createdBy.GetString() ?? ""; + } + + if (silence.TryGetProperty("startsAt", out JsonElement startsAt)) + { + DateTime.TryParse(startsAt.GetString(), out DateTime parsed); + info.StartsAt = parsed; + } + + if (silence.TryGetProperty("endsAt", out JsonElement endsAt)) + { + DateTime.TryParse(endsAt.GetString(), out DateTime parsed); + info.EndsAt = parsed; + } + + if (silence.TryGetProperty("matchers", out JsonElement matchers) + && matchers.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement m in matchers.EnumerateArray()) + { + SilenceMatcher matcher = new() + { + Name = m.TryGetProperty("name", out JsonElement n) ? n.GetString() ?? "" : "", + Value = m.TryGetProperty("value", out JsonElement v) ? v.GetString() ?? "" : "", + IsRegex = m.TryGetProperty("isRegex", out JsonElement r) && r.GetBoolean(), + IsEqual = !m.TryGetProperty("isEqual", out JsonElement e) || e.GetBoolean() + }; + info.Matchers.Add(matcher); + } + } + + results.Add(info); + } + } + catch + { + // Graceful degradation. + } + + return results; + } + + /// + /// Extracts Prometheus configuration from a component's Configuration JSON. + /// Falls back to standard kube-prometheus-stack defaults if parsing fails. + /// + public static PrometheusConfig? GetPrometheusConfig(ClusterComponent component) + { + if (string.IsNullOrWhiteSpace(component.Configuration)) + { + return new PrometheusConfig(); + } + + try + { + PrometheusConfig? config = JsonSerializer.Deserialize( + component.Configuration, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return config ?? new PrometheusConfig(); + } + catch + { + return new PrometheusConfig(); + } + } + + // ──────── Internal Helpers ──────── + + private static double ExtractScalarValue(string json) + { + List results = ParseInstantQueryResult(json); + return results.Count > 0 ? results[0].Value : 0; + } + + private static Kubernetes CreateClient(string kubeconfig) + { + using MemoryStream stream = new(Encoding.UTF8.GetBytes(kubeconfig)); + KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream); + return new Kubernetes(config); + } + + private static async Task ProxyGetAsync(Kubernetes client, string path, CancellationToken ct) + { + // Use the K8s API server proxy to reach in-cluster services. + + Uri requestUri = new(client.BaseUri, path); + HttpResponseMessage response = await client.HttpClient.GetAsync(requestUri, ct); + return await response.Content.ReadAsStringAsync(ct); + } + + private static async Task ProxyPostAsync(Kubernetes client, string path, string body, CancellationToken ct) + { + Uri requestUri = new(client.BaseUri, path); + StringContent content = new(body, Encoding.UTF8, "application/json"); + await client.HttpClient.PostAsync(requestUri, content, ct); + } +} diff --git a/src/EntKube.Web/Services/StorageService.cs b/src/EntKube.Web/Services/StorageService.cs new file mode 100644 index 0000000..38040cd --- /dev/null +++ b/src/EntKube.Web/Services/StorageService.cs @@ -0,0 +1,888 @@ +using System.Text.Json; +using EntKube.Web.Data; +using k8s; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// A MinIO bucket discovered from a cluster's MinIO tenant/instance. +/// +public class MinioBucketInfo +{ + public required string Name { get; set; } + public required string ClusterName { get; set; } + public required string EnvironmentName { get; set; } + public required string Namespace { get; set; } + public string? Endpoint { get; set; } + public string? Storage { get; set; } + public string? Status { get; set; } + public DateTime? CreatedAt { get; set; } + public Guid ClusterId { get; set; } +} + +/// +/// Manages storage discovery and links for a tenant: +/// - Discovers MinIO tenants/buckets from clusters with the MinIO operator installed +/// - CRUD for external storage links (AWS S3, Azure Storage, Cleura S3) +/// - Credentials for external storage are kept in the vault +/// +/// MinIO discovery reads the MinIO Tenant CRD from clusters. +/// External providers are registered manually — the service stores metadata +/// in StorageLink entities and credentials in the VaultSecret table. +/// +public class StorageService(IDbContextFactory dbFactory, VaultService vaultService, OpenStackS3Service openStackS3) +{ + // ──────── Operator Status ──────── + + /// + /// Checks if MinIO is installed on any of the tenant's clusters. + /// + public async Task IsMinioAvailableAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.ClusterComponents + .AnyAsync(c => c.Cluster.TenantId == tenantId + && c.Name == "minio" + && c.Status == ComponentStatus.Installed, ct); + } + + // ──────── MinIO Discovery ──────── + + /// + /// Discovers MinIO instances running on the tenant's clusters by querying + /// for MinIO Tenant CRDs or by checking the MinIO service endpoints. + /// Returns basic info about each MinIO deployment found. + /// + public async Task> GetMinioInstancesAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Find clusters with MinIO installed. + + List clusters = await db.KubernetesClusters + .Include(k => k.Environment) + .Include(k => k.Components) + .Where(k => k.TenantId == tenantId + && k.Components.Any(c => c.Name == "minio" && c.Status == ComponentStatus.Installed)) + .ToListAsync(ct); + + List results = []; + + foreach (KubernetesCluster cluster in clusters) + { + if (string.IsNullOrWhiteSpace(cluster.Kubeconfig)) + { + continue; + } + + try + { + List instances = await QueryMinioTenantsAsync(cluster, ct); + results.AddRange(instances); + } + catch + { + // Cluster unreachable — skip gracefully. + } + } + + return results; + } + + /// + /// Queries a cluster for MinIO Tenant CRDs (minio.min.io/v2). + /// + private static async Task> QueryMinioTenantsAsync( + KubernetesCluster cluster, CancellationToken ct) + { + using Kubernetes client = CreateClient(cluster.Kubeconfig!); + + object response = await client.CustomObjects.ListClusterCustomObjectAsync( + group: "minio.min.io", + version: "v2", + plural: "tenants", + cancellationToken: ct); + + JsonElement json = JsonSerializer.Deserialize( + JsonSerializer.Serialize(response)); + + List results = []; + + if (json.TryGetProperty("items", out JsonElement items)) + { + foreach (JsonElement item in items.EnumerateArray()) + { + MinioBucketInfo info = ParseMinioTenant(item, cluster); + results.Add(info); + } + } + + return results; + } + + private static MinioBucketInfo ParseMinioTenant(JsonElement item, KubernetesCluster cluster) + { + JsonElement metadata = item.GetProperty("metadata"); + + string name = metadata.GetProperty("name").GetString() ?? "unknown"; + string ns = metadata.GetProperty("namespace").GetString() ?? "default"; + + // Try to get storage and status from the spec/status. + + string? storage = null; + + if (item.TryGetProperty("spec", out JsonElement spec) + && spec.TryGetProperty("pools", out JsonElement pools)) + { + foreach (JsonElement pool in pools.EnumerateArray()) + { + if (pool.TryGetProperty("volumeClaimTemplate", out JsonElement vct) + && vct.TryGetProperty("spec", out JsonElement vctSpec) + && vctSpec.TryGetProperty("resources", out JsonElement res) + && res.TryGetProperty("requests", out JsonElement req) + && req.TryGetProperty("storage", out JsonElement storEl)) + { + storage = storEl.GetString(); + break; + } + } + } + + string status = "Unknown"; + + if (item.TryGetProperty("status", out JsonElement statusEl) + && statusEl.TryGetProperty("currentState", out JsonElement stateEl)) + { + status = stateEl.GetString() ?? "Unknown"; + } + + DateTime? createdAt = metadata.TryGetProperty("creationTimestamp", out JsonElement tsEl) + ? tsEl.GetDateTime() : null; + + // The MinIO service endpoint within the cluster. + + string endpoint = $"http://minio.{ns}.svc.cluster.local:9000"; + + return new MinioBucketInfo + { + Name = name, + ClusterName = cluster.Name, + EnvironmentName = cluster.Environment.Name, + Namespace = ns, + Endpoint = endpoint, + Storage = storage, + Status = status, + CreatedAt = createdAt, + ClusterId = cluster.Id + }; + } + + // ──────── Storage Links (External Providers) ──────── + + /// + /// Lists all storage links for a tenant, optionally filtered by environment. + /// + public async Task> GetStorageLinksAsync( + Guid tenantId, Guid? environmentId = null, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + IQueryable query = db.StorageLinks + .Include(s => s.Environment) + .Where(s => s.TenantId == tenantId); + + if (environmentId.HasValue) + { + query = query.Where(s => s.EnvironmentId == environmentId.Value); + } + + return await query.OrderBy(s => s.Provider).ThenBy(s => s.Name).ToListAsync(ct); + } + + /// + /// Creates a new external storage link and stores its credentials in the vault. + /// The access key and secret key are encrypted and kept as component-free vault secrets. + /// For Cleura S3, an OpenStack connection ID is required. + /// + public async Task CreateStorageLinkAsync( + Guid tenantId, + Guid environmentId, + StorageProvider provider, + string name, + string? endpoint, + string? bucketName, + string? region, + string? accessKey, + string? secretKey, + string? notes, + Guid? openStackConnectionId = null, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + EnvironmentId = environmentId, + Provider = provider, + Name = name, + Endpoint = endpoint, + BucketName = bucketName, + Region = region, + Notes = notes, + OpenStackConnectionId = openStackConnectionId + }; + + db.StorageLinks.Add(link); + await db.SaveChangesAsync(ct); + + // Store credentials in the vault scoped to this storage link. + + if (!string.IsNullOrWhiteSpace(accessKey)) + { + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "ACCESS_KEY", accessKey, ct); + } + + if (!string.IsNullOrWhiteSpace(secretKey)) + { + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "SECRET_KEY", secretKey, ct); + } + + return link; + } + + /// + /// Updates an existing storage link's metadata. Credentials are updated + /// separately via the vault. + /// + public async Task UpdateStorageLinkAsync( + Guid linkId, + string name, + string? endpoint, + string? bucketName, + string? region, + string? notes, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + StorageLink? link = await db.StorageLinks.FindAsync([linkId], ct); + + if (link is null) + { + return; + } + + link.Name = name; + link.Endpoint = endpoint; + link.BucketName = bucketName; + link.Region = region; + link.Notes = notes; + await db.SaveChangesAsync(ct); + } + + /// + /// Deletes a storage link and its associated vault secrets. + /// + public async Task DeleteStorageLinkAsync(Guid tenantId, Guid linkId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + StorageLink? link = await db.StorageLinks.FindAsync([linkId], ct); + + if (link is null) + { + return; + } + + // Remove vault secrets associated with this link. + + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, linkId, ct); + + foreach (VaultSecret secret in secrets) + { + await vaultService.DeleteSecretAsync(secret.Id, ct); + } + + db.StorageLinks.Remove(link); + await db.SaveChangesAsync(ct); + } + + // ──────── Cleura S3 Bucket Provisioning ──────── + + /// + /// Provisions a new Cleura S3 bucket end-to-end: + /// 1. Authenticates via OpenStack Keystone + /// 2. Creates EC2 credentials (S3 access/secret key) + /// 3. Creates the bucket via the S3 API + /// 4. Stores the credentials in the vault + /// 5. Creates a StorageLink record + /// + /// The user specifies the bucket name; the OpenStack connection provides + /// the authentication details and region. + /// + public async Task ProvisionCleuraS3BucketAsync( + Guid tenantId, + Guid environmentId, + Guid openStackConnectionId, + string bucketName, + string? displayName, + string? notes, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Load the OpenStack connection. + + OpenStackConnection connection = await db.OpenStackConnections + .FirstOrDefaultAsync(c => c.Id == openStackConnectionId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("OpenStack connection not found."); + + // Provision the bucket via OpenStack (Keystone auth → EC2 creds → S3 create). + + CleuraS3ProvisionResult result = await openStackS3.CreateBucketAsync( + tenantId, connection, bucketName, ct); + + // Create the storage link with the provisioned details. + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + EnvironmentId = environmentId, + Provider = StorageProvider.CleuraS3, + Name = displayName ?? bucketName, + Endpoint = result.Endpoint, + BucketName = result.BucketName, + Region = result.Region, + Notes = notes, + OpenStackConnectionId = openStackConnectionId + }; + + db.StorageLinks.Add(link); + await db.SaveChangesAsync(ct); + + // Store the generated S3 credentials and connection info in the vault. + // These secrets are the full set needed by any app or component to use the bucket. + + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "ACCESS_KEY", result.AccessKey, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "SECRET_KEY", result.SecretKey, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "ENDPOINT", result.Endpoint, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "BUCKET_NAME", result.BucketName, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, link.Id, "REGION", result.Region, ct); + + return link; + } + + // ──────── Cleura S3 Bucket Management ──────── + + /// + /// Lists all buckets accessible by the credentials stored for a given storage link. + /// This shows what buckets exist under the project — useful for verifying + /// provisioning and discovering buckets created outside EntKube. + /// + public async Task> ListCleuraS3BucketsAsync( + Guid tenantId, Guid linkId, CancellationToken ct = default) + { + StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct); + (string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct); + + return await openStackS3.ListBucketsAsync( + link.Endpoint!, accessKey, secretKey, link.Region!, ct); + } + + /// + /// Deletes a Cleura S3 bucket and removes the associated StorageLink. + /// The bucket must be empty. This is a destructive operation — the bucket + /// and all its metadata in EntKube will be permanently removed. + /// + public async Task DeleteCleuraS3BucketAsync( + Guid tenantId, Guid linkId, CancellationToken ct = default) + { + StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct); + (string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct); + + // Delete the actual bucket from the object store. + + await openStackS3.DeleteBucketAsync( + link.Endpoint!, accessKey, secretKey, link.BucketName!, link.Region!, ct); + + // Remove the StorageLink and vault secrets. + + await DeleteStorageLinkAsync(tenantId, linkId, ct); + } + + /// + /// Gets the CORS configuration for a Cleura S3 bucket. + /// Returns null if no CORS configuration exists. + /// + public async Task?> GetBucketCorsAsync( + Guid tenantId, Guid linkId, CancellationToken ct = default) + { + StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct); + (string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct); + + return await openStackS3.GetBucketCorsAsync( + link.Endpoint!, accessKey, secretKey, link.BucketName!, link.Region!, ct); + } + + /// + /// Sets the CORS configuration for a Cleura S3 bucket. + /// Pass an empty list to remove all CORS rules. + /// + public async Task SetBucketCorsAsync( + Guid tenantId, Guid linkId, List rules, CancellationToken ct = default) + { + StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct); + (string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct); + + await openStackS3.SetBucketCorsAsync( + link.Endpoint!, accessKey, secretKey, link.BucketName!, link.Region!, rules, ct); + } + + /// + /// Gets the bucket policy as a JSON string. + /// Returns null if no policy is set. + /// + public async Task GetBucketPolicyAsync( + Guid tenantId, Guid linkId, CancellationToken ct = default) + { + StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct); + (string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct); + + return await openStackS3.GetBucketPolicyAsync( + link.Endpoint!, accessKey, secretKey, link.BucketName!, link.Region!, ct); + } + + /// + /// Sets the bucket policy from a JSON string. Pass null to remove the policy. + /// + public async Task SetBucketPolicyAsync( + Guid tenantId, Guid linkId, string? policyJson, CancellationToken ct = default) + { + StorageLink link = await GetCleuraLinkOrThrowAsync(tenantId, linkId, ct); + (string accessKey, string secretKey) = await GetStoredCredentialsAsync(tenantId, linkId, ct); + + await openStackS3.SetBucketPolicyAsync( + link.Endpoint!, accessKey, secretKey, link.BucketName!, link.Region!, policyJson, ct); + } + + /// + /// Rotates the EC2 credentials for a Cleura S3 storage link. + /// Creates new credentials, verifies they work, and updates the vault. + /// The old credentials become invalid after this operation. + /// + public async Task RotateCleuraS3CredentialsAsync( + Guid tenantId, Guid linkId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + StorageLink link = await db.StorageLinks + .FirstOrDefaultAsync(l => l.Id == linkId && l.TenantId == tenantId && l.Provider == StorageProvider.CleuraS3, ct) + ?? throw new InvalidOperationException("Cleura S3 storage link not found."); + + OpenStackConnection connection = await db.OpenStackConnections + .FirstOrDefaultAsync(c => c.Id == link.OpenStackConnectionId && c.TenantId == tenantId, ct) + ?? throw new InvalidOperationException("OpenStack connection not found."); + + // Rotate credentials via Keystone (creates new EC2 credentials and verifies them). + + (string newAccessKey, string newSecretKey) = await openStackS3.RotateCredentialsAsync( + tenantId, connection, link.BucketName!, ct); + + // Update the vault with the new credentials. + + await vaultService.SetStorageLinkSecretAsync(tenantId, linkId, "ACCESS_KEY", newAccessKey, ct); + await vaultService.SetStorageLinkSecretAsync(tenantId, linkId, "SECRET_KEY", newSecretKey, ct); + } + + // ──────── Private Helpers ──────── + + /// + /// Loads a Cleura S3 StorageLink or throws if not found. + /// + private async Task GetCleuraLinkOrThrowAsync( + Guid tenantId, Guid linkId, CancellationToken ct) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.StorageLinks + .FirstOrDefaultAsync(l => l.Id == linkId && l.TenantId == tenantId && l.Provider == StorageProvider.CleuraS3, ct) + ?? throw new InvalidOperationException("Cleura S3 storage link not found."); + } + + /// + /// Retrieves the stored S3 credentials (access/secret key) from vault for a storage link. + /// + private async Task<(string AccessKey, string SecretKey)> GetStoredCredentialsAsync( + Guid tenantId, Guid linkId, CancellationToken ct) + { + string? accessKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, linkId, "ACCESS_KEY", ct); + string? secretKey = await vaultService.GetStorageLinkSecretValueAsync(tenantId, linkId, "SECRET_KEY", ct); + + if (string.IsNullOrWhiteSpace(accessKey) || string.IsNullOrWhiteSpace(secretKey)) + { + throw new InvalidOperationException( + "S3 credentials not found in vault. The credentials may need to be re-provisioned."); + } + + return (accessKey, secretKey); + } + + /// + /// Gets the list of environments for a tenant (for the UI dropdown). + /// + public async Task> GetEnvironmentsAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(e => e.TenantId == tenantId) + .OrderBy(e => e.Name) + .ToListAsync(ct); + } + + // ──────── OpenStack Connections ──────── + + /// + /// Lists all OpenStack connections for a tenant. + /// These are required for Cleura S3 storage management. + /// + public async Task> GetOpenStackConnectionsAsync( + Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.OpenStackConnections + .Where(c => c.TenantId == tenantId) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + /// + /// Creates a new OpenStack connection and stores the password in the vault. + /// The connection enables managing Cleura S3 buckets and credentials via + /// the OpenStack API (Keystone auth → Swift/S3 operations). + /// + public async Task CreateOpenStackConnectionAsync( + Guid tenantId, + string name, + string authUrl, + string? region, + string? projectName, + string? projectId, + string? userDomainName, + string? projectDomainName, + string? username, + string? password, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + OpenStackConnection connection = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = name, + AuthUrl = authUrl, + Region = region, + ProjectName = projectName, + ProjectId = projectId, + UserDomainName = userDomainName, + ProjectDomainName = projectDomainName, + Username = username + }; + + db.OpenStackConnections.Add(connection); + await db.SaveChangesAsync(ct); + + // Store the password in the vault, keyed by the connection ID. + + if (!string.IsNullOrWhiteSpace(password)) + { + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetOpenStackSecretAsync(tenantId, connection.Id, "OS_PASSWORD", password, ct); + } + + return connection; + } + + /// + /// Updates an OpenStack connection's metadata. Password is updated + /// separately via the vault if provided. + /// + public async Task UpdateOpenStackConnectionAsync( + Guid connectionId, + string name, + string authUrl, + string? region, + string? projectName, + string? projectId, + string? userDomainName, + string? projectDomainName, + string? username, + string? password, + Guid tenantId, + CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + OpenStackConnection? connection = await db.OpenStackConnections.FindAsync([connectionId], ct); + + if (connection is null) + { + return; + } + + connection.Name = name; + connection.AuthUrl = authUrl; + connection.Region = region; + connection.ProjectName = projectName; + connection.ProjectId = projectId; + connection.UserDomainName = userDomainName; + connection.ProjectDomainName = projectDomainName; + connection.Username = username; + await db.SaveChangesAsync(ct); + + // Update password if a new one was provided. + + if (!string.IsNullOrWhiteSpace(password)) + { + await vaultService.InitializeVaultAsync(tenantId, ct); + await vaultService.SetOpenStackSecretAsync(tenantId, connectionId, "OS_PASSWORD", password, ct); + } + } + + /// + /// Deletes an OpenStack connection and its vault-stored password. + /// Fails silently if any storage links still reference this connection. + /// + public async Task DeleteOpenStackConnectionAsync( + Guid tenantId, Guid connectionId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Don't delete if storage links depend on this connection. + + bool hasLinks = await db.StorageLinks + .AnyAsync(s => s.OpenStackConnectionId == connectionId, ct); + + if (hasLinks) + { + return false; + } + + OpenStackConnection? connection = await db.OpenStackConnections.FindAsync([connectionId], ct); + + if (connection is null) + { + return false; + } + + // Remove vault secret. + + List secrets = await vaultService.GetOpenStackSecretsAsync(tenantId, connectionId, ct); + + foreach (VaultSecret secret in secrets) + { + await vaultService.DeleteSecretAsync(secret.Id, ct); + } + + db.OpenStackConnections.Remove(connection); + await db.SaveChangesAsync(ct); + return true; + } + + // ══════════════════════════════════════════════════════════════ + // Storage Bindings + // ══════════════════════════════════════════════════════════════ + + /// + /// Binds a storage link to an app deployment. This tells the platform that + /// the deployment's workload needs access to this storage — so the credentials + /// should be projected as a Kubernetes Secret in the deployment's namespace. + /// + /// When the binding is created, any existing vault secrets for the storage link + /// are configured for K8s sync (setting the target secret name and namespace). + /// + public async Task BindStorageToDeploymentAsync( + Guid storageLinkId, Guid deploymentId, string kubernetesSecretName, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Create the binding record linking storage → deployment. + + StorageBinding binding = new() + { + Id = Guid.NewGuid(), + StorageLinkId = storageLinkId, + AppDeploymentId = deploymentId, + KubernetesSecretName = kubernetesSecretName + }; + + db.Set().Add(binding); + await db.SaveChangesAsync(ct); + + // Configure the storage link's vault secrets for K8s sync. + // We need the deployment's namespace to know where the K8s Secret goes. + + AppDeployment? deployment = await db.AppDeployments.FindAsync([deploymentId], ct); + + if (deployment is not null) + { + StorageLink? link = await db.StorageLinks.FindAsync([storageLinkId], ct); + + if (link is not null) + { + await ConfigureSecretsForSyncAsync( + link.TenantId, storageLinkId, kubernetesSecretName, deployment.Namespace, ct); + } + } + + return binding; + } + + /// + /// Binds a storage link to a cluster component. The credentials will be + /// projected as a Kubernetes Secret in the component's namespace. + /// + public async Task BindStorageToComponentAsync( + Guid storageLinkId, Guid componentId, string kubernetesSecretName, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Create the binding record linking storage → component. + + StorageBinding binding = new() + { + Id = Guid.NewGuid(), + StorageLinkId = storageLinkId, + ComponentId = componentId, + KubernetesSecretName = kubernetesSecretName + }; + + db.Set().Add(binding); + await db.SaveChangesAsync(ct); + + // Configure vault secrets for K8s sync using the component's namespace. + + ClusterComponent? component = await db.ClusterComponents.FindAsync([componentId], ct); + + if (component is not null && component.Namespace is not null) + { + StorageLink? link = await db.StorageLinks.FindAsync([storageLinkId], ct); + + if (link is not null) + { + await ConfigureSecretsForSyncAsync( + link.TenantId, storageLinkId, kubernetesSecretName, component.Namespace, ct); + } + } + + return binding; + } + + /// + /// Returns all storage bindings for a deployment, including the linked StorageLink + /// so the UI can display which storage providers are connected. + /// + public async Task> GetBindingsForDeploymentAsync( + Guid deploymentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Include(b => b.StorageLink) + .Where(b => b.AppDeploymentId == deploymentId) + .OrderBy(b => b.CreatedAt) + .ToListAsync(ct); + } + + /// + /// Returns all storage bindings for a cluster component. + /// + public async Task> GetBindingsForComponentAsync( + Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Include(b => b.StorageLink) + .Where(b => b.ComponentId == componentId) + .OrderBy(b => b.CreatedAt) + .ToListAsync(ct); + } + + /// + /// Removes a storage binding. When unbound, the vault secrets for the storage + /// link have their K8s sync disabled — the platform will no longer project + /// credentials into the workload's namespace. + /// + public async Task UnbindStorageAsync(Guid bindingId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + StorageBinding? binding = await db.Set() + .Include(b => b.StorageLink) + .FirstOrDefaultAsync(b => b.Id == bindingId, ct); + + if (binding is null) + { + return false; + } + + // Disable K8s sync on the storage link's secrets since this binding is going away. + + await DisableSecretsForSyncAsync(binding.StorageLink.TenantId, binding.StorageLinkId, ct); + + db.Set().Remove(binding); + await db.SaveChangesAsync(ct); + return true; + } + + // ──────── Internal ──────── + + /// + /// Marks all vault secrets for a storage link to sync to K8s with the + /// given secret name and namespace. + /// + private async Task ConfigureSecretsForSyncAsync( + Guid tenantId, Guid storageLinkId, string secretName, string ns, CancellationToken ct) + { + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, storageLinkId, ct); + + foreach (VaultSecret secret in secrets) + { + await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, secretName, ns, ct); + } + } + + /// + /// Disables K8s sync on all vault secrets for a storage link. + /// + private async Task DisableSecretsForSyncAsync( + Guid tenantId, Guid storageLinkId, CancellationToken ct) + { + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenantId, storageLinkId, ct); + + foreach (VaultSecret secret in secrets) + { + await vaultService.ConfigureKubernetesSyncAsync(secret.Id, false, null, null, ct); + } + } + + private static Kubernetes CreateClient(string kubeconfig) + { + using MemoryStream stream = new(System.Text.Encoding.UTF8.GetBytes(kubeconfig)); + KubernetesClientConfiguration config = KubernetesClientConfiguration.BuildConfigFromConfigFile(stream); + return new Kubernetes(config); + } +} diff --git a/src/EntKube.Web/Services/TenantService.cs b/src/EntKube.Web/Services/TenantService.cs new file mode 100644 index 0000000..c39ff5e --- /dev/null +++ b/src/EntKube.Web/Services/TenantService.cs @@ -0,0 +1,362 @@ +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Provides CRUD operations for tenants and their directly-owned children +/// (environments, customers, groups, clusters). Each method tells a simple +/// story: load, validate, persist, return. +/// +public class TenantService(IDbContextFactory dbFactory) +{ + // --- Tenant CRUD --- + + public async Task> GetAllTenantsAsync(CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Tenants.OrderBy(t => t.Name).ToListAsync(ct); + } + + public async Task GetTenantBySlugAsync(string slug, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Tenants.FirstOrDefaultAsync(t => t.Slug == slug, ct); + } + + public async Task CreateTenantAsync(string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // The slug is auto-generated from the name — users shouldn't have to think about URL encoding. + // We take the name, lowercase it, replace spaces and special chars with hyphens, + // collapse runs of hyphens, and trim leading/trailing hyphens. + + string slug = GenerateSlug(name); + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = name, Slug = slug }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(ct); + return tenant; + } + + /// + /// Converts a human-friendly name into a URL-safe slug. + /// "Acme Corp!" → "acme-corp", "My Cool Tenant" → "my-cool-tenant" + /// + public static string GenerateSlug(string name) + { + string slug = name.ToLowerInvariant(); + + // Replace any character that isn't a letter, digit, or hyphen with a hyphen. + char[] chars = new char[slug.Length]; + + for (int i = 0; i < slug.Length; i++) + { + char c = slug[i]; + chars[i] = char.IsLetterOrDigit(c) ? c : '-'; + } + + slug = new string(chars); + + // Collapse multiple consecutive hyphens into one. + while (slug.Contains("--")) + { + slug = slug.Replace("--", "-"); + } + + // Trim leading/trailing hyphens. + return slug.Trim('-'); + } + + public async Task UpdateTenantAsync(Guid id, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Tenant? tenant = await db.Tenants.FindAsync([id], ct); + + if (tenant is null) + { + return null; + } + + tenant.Name = name; + tenant.Slug = GenerateSlug(name); + await db.SaveChangesAsync(ct); + return tenant; + } + + public async Task DeleteTenantAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Tenant? tenant = await db.Tenants.FindAsync([id], ct); + + if (tenant is null) + { + return false; + } + + db.Tenants.Remove(tenant); + await db.SaveChangesAsync(ct); + return true; + } + + // --- Environment CRUD --- + + public async Task> GetEnvironmentsAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Environments + .Where(e => e.TenantId == tenantId) + .OrderBy(e => e.Name) + .ToListAsync(ct); + } + + public async Task CreateEnvironmentAsync(Guid tenantId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Data.Environment environment = new() { Id = Guid.NewGuid(), TenantId = tenantId, Name = name }; + db.Environments.Add(environment); + await db.SaveChangesAsync(ct); + return environment; + } + + public async Task DeleteEnvironmentAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Data.Environment? environment = await db.Environments.FindAsync([id], ct); + + if (environment is null) + { + return false; + } + + db.Environments.Remove(environment); + await db.SaveChangesAsync(ct); + return true; + } + + // --- Customer CRUD --- + + public async Task> GetCustomersAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Customers + .Where(c => c.TenantId == tenantId) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + public async Task GetCustomerAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Customers.FindAsync([id], ct); + } + + public async Task CreateCustomerAsync(Guid tenantId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenantId, Name = name }; + db.Customers.Add(customer); + await db.SaveChangesAsync(ct); + return customer; + } + + public async Task DeleteCustomerAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Customer? customer = await db.Customers.FindAsync([id], ct); + + if (customer is null) + { + return false; + } + + db.Customers.Remove(customer); + await db.SaveChangesAsync(ct); + return true; + } + + // --- App CRUD --- + + public async Task> GetAppsAsync(Guid customerId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Apps + .Where(a => a.CustomerId == customerId) + .Include(a => a.AppEnvironments) + .ThenInclude(ae => ae.Environment) + .OrderBy(a => a.Name) + .ToListAsync(ct); + } + + public async Task CreateAppAsync(Guid customerId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customerId, Name = name }; + db.Apps.Add(app); + await db.SaveChangesAsync(ct); + return app; + } + + public async Task DeleteAppAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + App? app = await db.Apps.FindAsync([id], ct); + + if (app is null) + { + return false; + } + + db.Apps.Remove(app); + await db.SaveChangesAsync(ct); + return true; + } + + public async Task LinkAppToEnvironmentAsync(Guid appId, Guid environmentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + bool exists = await db.AppEnvironments + .AnyAsync(ae => ae.AppId == appId && ae.EnvironmentId == environmentId, ct); + + if (!exists) + { + db.AppEnvironments.Add(new AppEnvironment { AppId = appId, EnvironmentId = environmentId }); + await db.SaveChangesAsync(ct); + } + } + + public async Task UnlinkAppFromEnvironmentAsync(Guid appId, Guid environmentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + AppEnvironment? link = await db.AppEnvironments + .FirstOrDefaultAsync(ae => ae.AppId == appId && ae.EnvironmentId == environmentId, ct); + + if (link is not null) + { + db.AppEnvironments.Remove(link); + await db.SaveChangesAsync(ct); + } + } + + // --- Group CRUD --- + + public async Task> GetGroupsAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Groups + .Where(g => g.TenantId == tenantId) + .Include(g => g.Memberships) + .OrderBy(g => g.Name) + .ToListAsync(ct); + } + + public async Task CreateGroupAsync(Guid tenantId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Group group = new() { Id = Guid.NewGuid(), TenantId = tenantId, Name = name }; + db.Groups.Add(group); + await db.SaveChangesAsync(ct); + return group; + } + + public async Task DeleteGroupAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + Group? group = await db.Groups.FindAsync([id], ct); + + if (group is null) + { + return false; + } + + db.Groups.Remove(group); + await db.SaveChangesAsync(ct); + return true; + } + + // --- Kubernetes Cluster CRUD --- + + public async Task> GetClustersAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.KubernetesClusters + .Where(c => c.TenantId == tenantId) + .Include(c => c.Environment) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + public async Task CreateClusterAsync( + Guid tenantId, Guid environmentId, string name, string apiServerUrl, + string? contextName = null, string? kubeconfig = null, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + EnvironmentId = environmentId, + Name = name, + ApiServerUrl = apiServerUrl, + ContextName = contextName, + Kubeconfig = kubeconfig + }; + + db.KubernetesClusters.Add(cluster); + await db.SaveChangesAsync(ct); + return cluster; + } + + public async Task DeleteClusterAsync(Guid id, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + KubernetesCluster? cluster = await db.KubernetesClusters.FindAsync([id], ct); + + if (cluster is null) + { + return false; + } + + db.KubernetesClusters.Remove(cluster); + await db.SaveChangesAsync(ct); + return true; + } + + // --- User lookup --- + + /// + /// Finds an application user by their email address. Used when granting + /// portal access — the admin types an email and we look up the user. + /// + public async Task FindUserByEmailAsync( + string email, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Users + .FirstOrDefaultAsync(u => u.Email == email, ct); + } +} diff --git a/src/EntKube.Web/Services/VaultEncryptionService.cs b/src/EntKube.Web/Services/VaultEncryptionService.cs new file mode 100644 index 0000000..c42fe85 --- /dev/null +++ b/src/EntKube.Web/Services/VaultEncryptionService.cs @@ -0,0 +1,127 @@ +using System.Security.Cryptography; +using System.Text; + +namespace EntKube.Web.Services; + +/// +/// Provides envelope encryption for the secrets vault. Two layers of AES-256-GCM: +/// +/// Layer 1 — Root key (from platform config) seals/unseals per-tenant Data Encryption Keys. +/// Layer 2 — Each tenant's DEK encrypts/decrypts individual secret values. +/// +/// This gives us: auto-unseal (root key always available from config), per-tenant isolation +/// (unique DEK per tenant), and envelope encryption (DB compromise alone doesn't expose secrets). +/// The root key can be rotated by re-encrypting all DEKs — without touching individual secrets. +/// +public class VaultEncryptionService +{ + private readonly byte[] rootKey; + + public VaultEncryptionService(byte[] rootKey) + { + // The root key must be exactly 256 bits for AES-256. + if (rootKey.Length != 32) + { + throw new ArgumentException("Root key must be exactly 32 bytes (256 bits).", nameof(rootKey)); + } + + this.rootKey = rootKey; + } + + /// + /// Generates a cryptographically random 256-bit data encryption key. + /// Called once when creating a new tenant vault. + /// + public byte[] GenerateDataKey() + { + byte[] key = new byte[32]; + RandomNumberGenerator.Fill(key); + return key; + } + + /// + /// Seals (encrypts) a tenant's data key using the platform root key. + /// The encrypted key and nonce are stored in the database alongside the vault. + /// + public (byte[] encryptedKey, byte[] nonce) SealDataKey(byte[] dataKey) + { + return EncryptBytes(rootKey, dataKey); + } + + /// + /// Unseals (decrypts) a tenant's data key using the platform root key. + /// This is the "auto-unseal" — the root key is always available from config, + /// so no manual intervention or key ceremony is needed. + /// + public byte[] UnsealDataKey(byte[] encryptedKey, byte[] nonce) + { + return DecryptBytes(rootKey, encryptedKey, nonce); + } + + /// + /// Encrypts a secret value using the tenant's data encryption key. + /// Each call generates a unique nonce, so identical plaintexts produce different ciphertexts. + /// + public (byte[] ciphertext, byte[] nonce) Encrypt(byte[] dataKey, string plaintext) + { + byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + return EncryptBytes(dataKey, plaintextBytes); + } + + /// + /// Decrypts a secret value using the tenant's data encryption key. + /// Returns the original plaintext string. + /// + public string Decrypt(byte[] dataKey, byte[] ciphertext, byte[] nonce) + { + byte[] plaintextBytes = DecryptBytes(dataKey, ciphertext, nonce); + return Encoding.UTF8.GetString(plaintextBytes); + } + + // --- Private helpers for AES-256-GCM --- + + private static (byte[] ciphertext, byte[] nonce) EncryptBytes(byte[] key, byte[] plaintext) + { + // AES-GCM uses a 12-byte nonce (96 bits) per NIST recommendation. + byte[] nonce = new byte[AesGcm.NonceByteSizes.MaxSize]; + RandomNumberGenerator.Fill(nonce); + + // The tag is 16 bytes (128 bits) — appended to the ciphertext for storage simplicity. + byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; + byte[] ciphertext = new byte[plaintext.Length]; + + using (AesGcm aes = new(key, AesGcm.TagByteSizes.MaxSize)) + { + aes.Encrypt(nonce, plaintext, ciphertext, tag); + } + + // Combine ciphertext + tag into a single byte array for simple storage. + byte[] combined = new byte[ciphertext.Length + tag.Length]; + Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length); + + return (combined, nonce); + } + + private static byte[] DecryptBytes(byte[] key, byte[] combinedCiphertext, byte[] nonce) + { + // Split the combined array back into ciphertext and tag. + int tagLength = AesGcm.TagByteSizes.MaxSize; + int ciphertextLength = combinedCiphertext.Length - tagLength; + + byte[] ciphertext = new byte[ciphertextLength]; + byte[] tag = new byte[tagLength]; + + Buffer.BlockCopy(combinedCiphertext, 0, ciphertext, 0, ciphertextLength); + Buffer.BlockCopy(combinedCiphertext, ciphertextLength, tag, 0, tagLength); + + byte[] plaintext = new byte[ciphertextLength]; + + using (AesGcm aes = new(key, AesGcm.TagByteSizes.MaxSize)) + { + aes.Decrypt(nonce, ciphertext, tag, plaintext); + } + + return plaintext; + } +} diff --git a/src/EntKube.Web/Services/VaultService.cs b/src/EntKube.Web/Services/VaultService.cs new file mode 100644 index 0000000..a9e45ce --- /dev/null +++ b/src/EntKube.Web/Services/VaultService.cs @@ -0,0 +1,638 @@ +using EntKube.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Services; + +/// +/// Manages the per-tenant secrets vault: initializing vaults, storing/retrieving +/// encrypted secrets for apps and cluster components, and configuring Kubernetes +/// sync. Each operation unseals the vault transparently (auto-unseal via root key). +/// +public class VaultService(IDbContextFactory dbFactory, VaultEncryptionService encryption) +{ + // --- Vault Lifecycle --- + + /// + /// Creates a new vault for the tenant if one doesn't already exist. + /// Generates a fresh DEK and seals it with the platform root key. + /// Idempotent — returns the existing vault if already initialized. + /// + public async Task InitializeVaultAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // If the tenant already has a vault, just return it. + SecretVault? existing = await db.Set() + .FirstOrDefaultAsync(v => v.TenantId == tenantId, ct); + + if (existing is not null) + { + return existing; + } + + // Generate a brand new data encryption key and seal it with the root key. + byte[] dataKey = encryption.GenerateDataKey(); + (byte[] encryptedKey, byte[] nonce) = encryption.SealDataKey(dataKey); + + SecretVault vault = new() + { + Id = Guid.NewGuid(), + TenantId = tenantId, + EncryptedDataKey = encryptedKey, + Nonce = nonce + }; + + db.Set().Add(vault); + await db.SaveChangesAsync(ct); + return vault; + } + + /// + /// Retrieves the vault for a tenant, or null if not yet initialized. + /// + public async Task GetVaultAsync(Guid tenantId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .FirstOrDefaultAsync(v => v.TenantId == tenantId, ct); + } + + // --- App Secrets --- + + /// + /// Stores or updates a secret for a customer app. If a secret with the same + /// name already exists for this app, its value is updated in place. + /// + public async Task SetAppSecretAsync( + Guid tenantId, Guid appId, string name, string value, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + // Unseal the vault to get the tenant's DEK. + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + + // Check if a secret with this name already exists for this app. + VaultSecret? existing = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.AppId == appId && s.Name == name, ct); + + // Encrypt the value with the tenant's DEK. + (byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value); + + if (existing is not null) + { + // Update the existing secret's encrypted value. + existing.EncryptedValue = ciphertext; + existing.Nonce = nonce; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return existing; + } + + // Create a new secret entry. + SecretVault vault = (await GetVaultAsync(tenantId, ct))!; + + VaultSecret secret = new() + { + Id = Guid.NewGuid(), + VaultId = vault.Id, + Name = name, + EncryptedValue = ciphertext, + Nonce = nonce, + AppId = appId + }; + + db.Set().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// Decrypts and returns a specific app secret value. + /// + public async Task GetAppSecretValueAsync( + Guid tenantId, Guid appId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.AppId == appId && s.Name == name, ct); + + if (secret is null) + { + return null; + } + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce); + } + + /// + /// Lists all secrets for an app (metadata only — values remain encrypted). + /// + public async Task> GetAppSecretsAsync( + Guid tenantId, Guid appId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(s => s.Vault.TenantId == tenantId && s.AppId == appId) + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + // --- Component Secrets --- + + /// + /// Stores or updates a secret for a cluster component. + /// + public async Task SetComponentSecretAsync( + Guid tenantId, Guid componentId, string name, string value, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + + VaultSecret? existing = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.ComponentId == componentId && s.Name == name, ct); + + (byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value); + + if (existing is not null) + { + existing.EncryptedValue = ciphertext; + existing.Nonce = nonce; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return existing; + } + + SecretVault vault = (await GetVaultAsync(tenantId, ct))!; + + VaultSecret secret = new() + { + Id = Guid.NewGuid(), + VaultId = vault.Id, + Name = name, + EncryptedValue = ciphertext, + Nonce = nonce, + ComponentId = componentId + }; + + db.Set().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// Decrypts and returns a specific component secret value. + /// + public async Task GetComponentSecretValueAsync( + Guid tenantId, Guid componentId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.ComponentId == componentId && s.Name == name, ct); + + if (secret is null) + { + return null; + } + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce); + } + + /// + /// Lists all secrets for a component (metadata only). + /// + public async Task> GetComponentSecretsAsync( + Guid tenantId, Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(s => s.Vault.TenantId == tenantId && s.ComponentId == componentId) + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + // --- Storage Link Secrets --- + + /// + /// Creates or updates a secret scoped to a storage link. + /// + public async Task SetStorageLinkSecretAsync( + Guid tenantId, Guid storageLinkId, string name, string value, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + + VaultSecret? existing = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.StorageLinkId == storageLinkId && s.Name == name, ct); + + (byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value); + + if (existing is not null) + { + existing.EncryptedValue = ciphertext; + existing.Nonce = nonce; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return existing; + } + + SecretVault vault = (await GetVaultAsync(tenantId, ct))!; + + VaultSecret secret = new() + { + Id = Guid.NewGuid(), + VaultId = vault.Id, + Name = name, + EncryptedValue = ciphertext, + Nonce = nonce, + StorageLinkId = storageLinkId + }; + + db.Set().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// Returns all secrets scoped to a storage link. + /// + public async Task> GetStorageLinkSecretsAsync( + Guid tenantId, Guid storageLinkId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(s => s.Vault.TenantId == tenantId && s.StorageLinkId == storageLinkId) + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + /// + /// Gets a single decrypted secret value for a storage link by name. + /// Returns null if the secret doesn't exist. + /// + public async Task GetStorageLinkSecretValueAsync( + Guid tenantId, Guid storageLinkId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.StorageLinkId == storageLinkId && s.Name == name, ct); + + if (secret is null) + { + return null; + } + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce); + } + + // --- OpenStack Connection Secrets --- + + /// + /// Creates or updates a secret scoped to an OpenStack connection. + /// + public async Task SetOpenStackSecretAsync( + Guid tenantId, Guid openStackConnectionId, string name, string value, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + + VaultSecret? existing = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.OpenStackConnectionId == openStackConnectionId && s.Name == name, ct); + + (byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value); + + if (existing is not null) + { + existing.EncryptedValue = ciphertext; + existing.Nonce = nonce; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return existing; + } + + SecretVault vault = (await GetVaultAsync(tenantId, ct))!; + + VaultSecret secret = new() + { + Id = Guid.NewGuid(), + VaultId = vault.Id, + Name = name, + EncryptedValue = ciphertext, + Nonce = nonce, + OpenStackConnectionId = openStackConnectionId + }; + + db.Set().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// Returns all secrets scoped to an OpenStack connection. + /// + public async Task> GetOpenStackSecretsAsync( + Guid tenantId, Guid openStackConnectionId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(s => s.Vault.TenantId == tenantId && s.OpenStackConnectionId == openStackConnectionId) + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + /// + /// Decrypts and returns a specific OpenStack connection secret by name. + /// + public async Task GetOpenStackSecretValueAsync( + Guid tenantId, Guid openStackConnectionId, string name, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.OpenStackConnectionId == openStackConnectionId && s.Name == name, ct); + + if (secret is null) + { + return null; + } + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce); + } + + // --- CNPG Database Secrets --- + + /// + /// Creates or updates a secret scoped to a CNPG database, pre-configured + /// for Kubernetes sync so applications can consume connection credentials + /// as a standard K8s Secret. + /// + public async Task SetCnpgDatabaseSecretAsync( + Guid tenantId, Guid cnpgDatabaseId, string name, string value, + string k8sSecretName, string k8sNamespace, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + byte[] dataKey = await UnsealVaultAsync(tenantId, ct); + + VaultSecret? existing = await db.Set() + .FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId && s.CnpgDatabaseId == cnpgDatabaseId && s.Name == name, ct); + + (byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value); + + if (existing is not null) + { + existing.EncryptedValue = ciphertext; + existing.Nonce = nonce; + existing.SyncToKubernetes = true; + existing.KubernetesSecretName = k8sSecretName; + existing.KubernetesNamespace = k8sNamespace; + existing.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return existing; + } + + SecretVault vault = (await GetVaultAsync(tenantId, ct))!; + + VaultSecret secret = new() + { + Id = Guid.NewGuid(), + VaultId = vault.Id, + Name = name, + EncryptedValue = ciphertext, + Nonce = nonce, + CnpgDatabaseId = cnpgDatabaseId, + SyncToKubernetes = true, + KubernetesSecretName = k8sSecretName, + KubernetesNamespace = k8sNamespace + }; + + db.Set().Add(secret); + await db.SaveChangesAsync(ct); + return secret; + } + + /// + /// Returns all secrets scoped to a CNPG database. + /// + public async Task> GetCnpgDatabaseSecretsAsync( + Guid tenantId, Guid cnpgDatabaseId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(s => s.Vault.TenantId == tenantId && s.CnpgDatabaseId == cnpgDatabaseId) + .OrderBy(s => s.Name) + .ToListAsync(ct); + } + + // --- Secret Management --- + + /// + /// Decrypts and returns the value of any secret by its ID, regardless of scope. + /// Used by the UI to reveal a secret value on demand. + /// + public async Task GetSecretValueByIdAsync(Guid secretId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .Include(s => s.Vault) + .FirstOrDefaultAsync(s => s.Id == secretId, ct); + + if (secret is null) + { + return null; + } + + byte[] dataKey = await UnsealVaultAsync(secret.Vault.TenantId, ct); + return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce); + } + + /// + /// Updates the encrypted value of an existing secret by its ID. + /// + public async Task UpdateSecretValueAsync(Guid secretId, string newValue, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set() + .Include(s => s.Vault) + .FirstOrDefaultAsync(s => s.Id == secretId, ct); + + if (secret is null) + { + return false; + } + + byte[] dataKey = await UnsealVaultAsync(secret.Vault.TenantId, ct); + (byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, newValue); + + secret.EncryptedValue = ciphertext; + secret.Nonce = nonce; + secret.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return true; + } + + /// + /// Checks whether a secret can be safely deleted. A secret is protected from + /// deletion when it belongs to a StorageLink that has active StorageBindings + /// (i.e. a workload depends on those credentials being available). + /// + public async Task<(bool CanDelete, string? Reason)> CanDeleteSecretAsync( + Guid secretId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set().FindAsync([secretId], ct); + + if (secret is null) + { + return (true, null); + } + + // If the secret belongs to a storage link, check for active bindings. + + if (secret.StorageLinkId is not null) + { + bool hasBindings = await db.Set() + .AnyAsync(b => b.StorageLinkId == secret.StorageLinkId && b.SyncEnabled, ct); + + if (hasBindings) + { + return (false, "This secret is linked to a storage binding that syncs to Kubernetes. Unbind the storage first."); + } + } + + // If the secret is synced to K8s, warn but still allow deletion. + + return (true, null); + } + + /// + /// Deletes a secret by ID regardless of its scope. + /// + public async Task DeleteSecretAsync(Guid secretId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set().FindAsync([secretId], ct); + + if (secret is null) + { + return false; + } + + db.Set().Remove(secret); + await db.SaveChangesAsync(ct); + return true; + } + + /// + /// Configures Kubernetes sync for a secret — enabling or disabling sync + /// and setting the target Secret name and namespace. + /// + public async Task ConfigureKubernetesSyncAsync( + Guid secretId, bool syncEnabled, string? secretName, string? ns, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + VaultSecret? secret = await db.Set().FindAsync([secretId], ct); + + if (secret is null) + { + return; + } + + secret.SyncToKubernetes = syncEnabled; + secret.KubernetesSecretName = secretName; + secret.KubernetesNamespace = ns; + secret.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + } + + // --- Cluster Components --- + + /// + /// Adds a new component (helm chart, deployment, etc.) to a cluster. + /// + public async Task CreateComponentAsync( + Guid clusterId, string name, string componentType, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = clusterId, + Name = name, + ComponentType = componentType + }; + + db.Set().Add(component); + await db.SaveChangesAsync(ct); + return component; + } + + /// + /// Lists all components for a cluster. + /// + public async Task> GetComponentsAsync(Guid clusterId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + return await db.Set() + .Where(c => c.ClusterId == clusterId) + .OrderBy(c => c.Name) + .ToListAsync(ct); + } + + /// + /// Deletes a component by ID. Cascade will also remove its secrets. + /// + public async Task DeleteComponentAsync(Guid componentId, CancellationToken ct = default) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + ClusterComponent? component = await db.Set().FindAsync([componentId], ct); + + if (component is null) + { + return false; + } + + db.Set().Remove(component); + await db.SaveChangesAsync(ct); + return true; + } + + // --- Private Helpers --- + + /// + /// Unseals the tenant's vault and returns the decrypted DEK. + /// This is the "auto-unseal" — we just decrypt using the root key from config. + /// + private async Task UnsealVaultAsync(Guid tenantId, CancellationToken ct) + { + using ApplicationDbContext db = dbFactory.CreateDbContext(); + + SecretVault vault = await db.Set() + .FirstAsync(v => v.TenantId == tenantId, ct); + + return encryption.UnsealDataKey(vault.EncryptedDataKey, vault.Nonce); + } +} diff --git a/src/EntKube.Web/Services/YamlFormMerger.cs b/src/EntKube.Web/Services/YamlFormMerger.cs new file mode 100644 index 0000000..3094e26 --- /dev/null +++ b/src/EntKube.Web/Services/YamlFormMerger.cs @@ -0,0 +1,291 @@ +using YamlDotNet.RepresentationModel; + +namespace EntKube.Web.Services; + +/// +/// A form field describes a single configurable value that can be edited +/// via a simple form control instead of requiring the user to hand-edit YAML. +/// Each field maps to a dot-notation path in the Helm values YAML, so when +/// the user fills in the form, the value gets merged into the correct spot. +/// +/// Think of this as making the YAML "approachable" — you can always drop +/// into the advanced YAML editor for full control, but common settings +/// (like passwords, port numbers, storage sizes) get a proper form field. +/// +public class ComponentFormField +{ + /// Unique key for this field within a catalog entry. + public required string Key { get; init; } + + /// Human-readable label shown next to the form control. + public required string Label { get; init; } + + /// + /// Dot-notation path into the YAML where this value lives. + /// For example "grafana.adminPassword" maps to: + /// grafana: + /// adminPassword: <value> + /// + public required string YamlPath { get; init; } + + /// What kind of form control to render. + public required FormFieldType Type { get; init; } + + /// Default value for the field (shown when the form first renders). + public string? DefaultValue { get; init; } + + /// Placeholder text for text-like inputs. + public string? Placeholder { get; init; } + + /// Short help text shown below the field. + public string? HelpText { get; init; } + + /// Fixed options for Select fields. + public IReadOnlyList? Options { get; init; } + + /// + /// When true, this field's value is stored as an encrypted secret in the + /// tenant vault rather than in plain text in the YAML values. At install time, + /// the secret is retrieved from the vault and injected into the Helm values. + /// + public bool StoreAsSecret { get; init; } + + /// + /// The name to use when storing this field as a vault secret. + /// Defaults to the field Key if not specified. + /// + public string? SecretName { get; init; } +} + +/// +/// The type of form control to render for a ComponentFormField. +/// +public enum FormFieldType +{ + Text, + Number, + Password, + Toggle, + Select +} + +/// +/// Takes form field values (dot-notation path → value) and merges them into +/// a YAML document. This bridges the gap between the friendly form UI and the +/// underlying Helm values YAML that actually drives configuration. +/// +/// The merge is additive: existing YAML keys not targeted by form fields are +/// preserved. If a form field targets a path that already exists, its value +/// is overwritten. If the path doesn't exist, the necessary structure is created. +/// +public static class YamlFormMerger +{ + /// + /// Merges form values into a base YAML string. Each form value is identified + /// by a dot-notation path (e.g. "grafana.adminPassword") and gets set at the + /// corresponding location in the YAML tree. + /// + /// If the base YAML is empty or null, a new document is created from scratch. + /// Boolean-like values ("true"/"false") are stored as YAML booleans (unquoted). + /// Numeric values are stored as YAML scalars (unquoted). + /// + public static string MergeFormValues(string baseYaml, IReadOnlyDictionary formValues) + { + // If there are no form values to apply, just return the original YAML + // exactly as-is — no point in parsing and re-serializing. + + if (formValues.Count == 0) + { + return baseYaml; + } + + // Parse the base YAML into a mutable tree we can manipulate. + // If the YAML is empty, start with an empty mapping node. + + YamlMappingNode root = ParseOrCreateRoot(baseYaml); + + // For each form value, walk the dot-notation path and set the leaf value. + // We create intermediate mapping nodes as needed. + + foreach (KeyValuePair entry in formValues) + { + string[] segments = entry.Key.Split('.'); + SetValueAtPath(root, segments, entry.Value); + } + + // Serialize the modified tree back to a YAML string. + + return SerializeToYaml(root); + } + + /// + /// Parses existing YAML into a mapping node, or creates an empty one + /// if the input is null/whitespace. + /// + private static YamlMappingNode ParseOrCreateRoot(string yaml) + { + if (string.IsNullOrWhiteSpace(yaml)) + { + return new YamlMappingNode(); + } + + YamlStream stream = new(); + stream.Load(new StringReader(yaml)); + + if (stream.Documents.Count == 0 || stream.Documents[0].RootNode is not YamlMappingNode mapping) + { + return new YamlMappingNode(); + } + + return mapping; + } + + /// + /// Walks the path segments through the YAML tree, creating intermediate + /// mapping nodes as needed, and sets the final leaf to the given value. + /// + private static void SetValueAtPath(YamlMappingNode current, string[] segments, string value) + { + // Walk down the path, creating mappings for intermediate segments. + // On the last segment, we set the scalar value. + + for (int i = 0; i < segments.Length - 1; i++) + { + YamlScalarNode key = new(segments[i]); + YamlNode? existing = current.Children.Keys + .OfType() + .FirstOrDefault(k => k.Value == segments[i]); + + if (existing is not null && current.Children[existing] is YamlMappingNode childMapping) + { + current = childMapping; + } + else + { + // Either the key doesn't exist or it points to a non-mapping. + // We create a new mapping node and replace whatever was there. + + YamlMappingNode newMapping = new(); + + if (existing is not null) + { + current.Children.Remove(existing); + } + + current.Children[key] = newMapping; + current = newMapping; + } + } + + // Set the leaf value. We determine the appropriate YAML scalar style + // based on the value content — booleans and numbers stay unquoted. + + string leafKey = segments[^1]; + YamlScalarNode? existingLeafKey = current.Children.Keys + .OfType() + .FirstOrDefault(k => k.Value == leafKey); + + YamlScalarNode valueNode = CreateScalarNode(value); + + if (existingLeafKey is not null) + { + current.Children[existingLeafKey] = valueNode; + } + else + { + current.Children[new YamlScalarNode(leafKey)] = valueNode; + } + } + + /// + /// Creates a YAML scalar node with appropriate style — booleans and numbers + /// stay unquoted (plain), strings that might be ambiguous get quoted. + /// + private static YamlScalarNode CreateScalarNode(string value) + { + // Booleans and numbers should be plain (unquoted) in YAML. + // Everything else is also plain unless it contains special chars. + + bool isBoolean = value is "true" or "false"; + bool isNumeric = double.TryParse(value, out _); + + return new YamlScalarNode(value) + { + Style = isBoolean || isNumeric + ? YamlDotNet.Core.ScalarStyle.Plain + : YamlDotNet.Core.ScalarStyle.Plain + }; + } + + /// + /// Serializes the YAML tree back to a string without the document markers (---/...). + /// + private static string SerializeToYaml(YamlMappingNode root) + { + YamlDocument document = new(root); + YamlStream stream = new(document); + + using StringWriter writer = new(); + stream.Save(writer, assignAnchors: false); + + // YamlDotNet adds "..." at the end and "---" at the start. Strip them + // for cleaner output that matches what users expect in Helm values. + + string output = writer.ToString(); + output = output.Replace("---\r\n", "").Replace("---\n", ""); + output = output.TrimEnd('\r', '\n', ' '); + + if (output.EndsWith("...")) + { + output = output[..^3].TrimEnd('\r', '\n', ' '); + } + + return output; + } + + /// + /// Extracts a scalar value from a YAML string at the given dot-notation path. + /// Returns null if the path doesn't exist or doesn't point to a scalar. + /// Used to read current form field values from existing component YAML. + /// + public static string? ExtractValue(string yaml, string dotPath) + { + if (string.IsNullOrWhiteSpace(yaml)) + { + return null; + } + + YamlMappingNode root = ParseOrCreateRoot(yaml); + string[] segments = dotPath.Split('.'); + YamlMappingNode current = root; + + // Walk down the tree following the path segments. + + for (int i = 0; i < segments.Length - 1; i++) + { + YamlNode? key = current.Children.Keys + .OfType() + .FirstOrDefault(k => k.Value == segments[i]); + + if (key is null || current.Children[key] is not YamlMappingNode child) + { + return null; + } + + current = child; + } + + // Read the leaf value. + + YamlNode? leafKey = current.Children.Keys + .OfType() + .FirstOrDefault(k => k.Value == segments[^1]); + + if (leafKey is not null && current.Children[leafKey] is YamlScalarNode scalar) + { + return scalar.Value; + } + + return null; + } +} diff --git a/src/EntKube.Web/appsettings.Development.json b/src/EntKube.Web/appsettings.Development.json index ae03514..95ec744 100644 --- a/src/EntKube.Web/appsettings.Development.json +++ b/src/EntKube.Web/appsettings.Development.json @@ -1,22 +1,15 @@ { + "DatabaseProvider": "Postgres", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=entkube;Username=postgres;Password=Popillol1!" + }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, - "Services": { - "Clusters": { - "BaseUrl": "http://localhost:5010" - }, - "Provisioning": { - "BaseUrl": "http://localhost:5020" - }, - "Identity": { - "BaseUrl": "http://localhost:5030" - }, - "Secrets": { - "BaseUrl": "http://localhost:5040" - } + "Vault": { + "RootKey": "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=" } } diff --git a/src/EntKube.Web/appsettings.Production.json b/src/EntKube.Web/appsettings.Production.json new file mode 100644 index 0000000..448f1ce --- /dev/null +++ b/src/EntKube.Web/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "DatabaseProvider": "Postgres", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=entkube;Username=entkube;Password=CHANGE_ME" + } +} diff --git a/src/EntKube.Web/appsettings.json b/src/EntKube.Web/appsettings.json index 1070196..56dbb9d 100644 --- a/src/EntKube.Web/appsettings.json +++ b/src/EntKube.Web/appsettings.json @@ -1,4 +1,5 @@ { + "DatabaseProvider": "Sqlite", "ConnectionStrings": { "DefaultConnection": "DataSource=Data/app.db;Cache=Shared" }, diff --git a/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs b/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs deleted file mode 100644 index 38ccaee..0000000 --- a/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs +++ /dev/null @@ -1,451 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Domain; - -public class KubernetesClusterTests -{ - [Fact] - public void Register_WithValidInputs_CreatesClusterInPendingState() - { - // Arrange & Act — A tenant admin registers a new cluster with its name, API URL, - // kubeconfig, tenant, and environment it belongs to. - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - KubernetesCluster cluster = KubernetesCluster.Register( - "production-eu", - "https://k8s.example.com:6443", - "apiVersion: v1\nkind: Config", - tenantId, - "prod-context", - environmentId); - - // Assert — The cluster should be created with a unique ID, linked to the tenant - // and environment, and start in Pending state because we haven't verified connectivity yet. - - cluster.Id.Should().NotBe(Guid.Empty); - cluster.Name.Should().Be("production-eu"); - cluster.ApiServerUrl.Should().Be("https://k8s.example.com:6443"); - cluster.KubeConfig.Should().Contain("apiVersion"); - cluster.TenantId.Should().Be(tenantId); - cluster.EnvironmentId.Should().Be(environmentId); - cluster.ContextName.Should().Be("prod-context"); - cluster.Status.Should().Be(ClusterStatus.Pending); - cluster.RegisteredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - } - - [Fact] - public void Register_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act — Attempting to register a cluster without a name should fail - // because every cluster needs a human-readable identifier. - - Action act = () => KubernetesCluster.Register("", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Register_WithEmptyApiServerUrl_ThrowsArgumentException() - { - // Arrange & Act — Without an API URL, we can't communicate with the cluster. - - Action act = () => KubernetesCluster.Register("my-cluster", "", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Assert - - act.Should().Throw() - .WithParameterName("apiServerUrl"); - } - - [Fact] - public void MarkConnected_SetsStatusToConnected() - { - // Arrange — A freshly registered cluster in Pending state. - - KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Act — The health check confirms the cluster is reachable. - - cluster.MarkConnected(); - - // Assert - - cluster.Status.Should().Be(ClusterStatus.Connected); - cluster.LastHealthCheckAt.Should().NotBeNull(); - } - - [Fact] - public void MarkUnreachable_SetsStatusToUnreachable() - { - // Arrange - - KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - // Act — The health check fails. - - cluster.MarkUnreachable(); - - // Assert - - cluster.Status.Should().Be(ClusterStatus.Unreachable); - cluster.LastHealthCheckAt.Should().NotBeNull(); - } - - [Fact] - public void Register_WithEnvironmentId_StoresEnvironmentId() - { - // Arrange & Act — A cluster is registered and immediately assigned - // to an environment (e.g. dev01 → "dev" environment). - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - KubernetesCluster cluster = KubernetesCluster.Register( - "dev01", "https://api.dev", "kubeconfig", tenantId, "dev-ctx", environmentId); - - // Assert — The environment should be stored on the cluster. - - cluster.EnvironmentId.Should().Be(environmentId); - } - - [Fact] - public void Register_WithoutEnvironmentId_ThrowsArgumentException() - { - // Arrange & Act — Attempting to register a cluster without an environment - // should fail because every cluster must belong to an environment. - - Action act = () => KubernetesCluster.Register( - "orphan", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.Empty); - - // Assert - - act.Should().Throw() - .WithParameterName("environmentId"); - } - - [Fact] - public void AssignToEnvironment_ReassignsToNewEnvironment() - { - // Arrange — A cluster registered in one environment can be reassigned. - - Guid originalEnvId = Guid.NewGuid(); - Guid newEnvId = Guid.NewGuid(); - KubernetesCluster cluster = KubernetesCluster.Register( - "test01", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", originalEnvId); - - // Act — Admin reassigns it to a different environment. - - cluster.AssignToEnvironment(newEnvId); - - // Assert - - cluster.EnvironmentId.Should().Be(newEnvId); - } - - [Fact] - public void AssignToEnvironment_WithEmptyGuid_ThrowsArgumentException() - { - // Arrange - - KubernetesCluster cluster = KubernetesCluster.Register( - "test01", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Act - - Action act = () => cluster.AssignToEnvironment(Guid.Empty); - - // Assert - - act.Should().Throw() - .WithParameterName("environmentId"); - } - - [Fact] - public void Register_CreatesClusterWithEmptyComponents() - { - // Arrange & Act — A freshly registered cluster has no known components yet. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Assert - - cluster.Components.Should().BeEmpty(); - } - - [Fact] - public void UpdateComponents_WithCheckResults_PersistsComponents() - { - // Arrange — After an adoption scan, we record the discovered components - // on the cluster so they survive without needing to re-scan. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List results = new() - { - new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Pod running" }, new List(), - new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary { ["replicas"] = "3" })), - new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled, - new List(), new List { "No pods found" }) - }; - - // Act - - cluster.UpdateComponents(results); - - // Assert — Both components stored with their status and configuration. - - cluster.Components.Should().HaveCount(2); - - ClusterComponent kyverno = cluster.Components.First(c => c.ComponentName == "kyverno"); - kyverno.Status.Should().Be(ComponentStatus.Installed); - kyverno.Version.Should().Be("3.3.4"); - kyverno.Namespace.Should().Be("kyverno"); - kyverno.Configuration["replicas"].Should().Be("3"); - kyverno.LastCheckedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - - ClusterComponent certManager = cluster.Components.First(c => c.ComponentName == "cert-manager"); - certManager.Status.Should().Be(ComponentStatus.NotInstalled); - certManager.Configuration.Should().BeEmpty(); - } - - [Fact] - public void UpdateComponents_ReplacesExistingComponents() - { - // Arrange — A second scan should replace the first scan's results entirely. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List firstScan = new() - { - new ComponentCheckResult("kyverno", ComponentStatus.NotInstalled, new List(), new List { "Missing" }) - }; - - List secondScan = new() - { - new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Running" }, new List(), - new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary())) - }; - - // Act - - cluster.UpdateComponents(firstScan); - cluster.UpdateComponents(secondScan); - - // Assert — Only the latest scan results should remain. - - cluster.Components.Should().HaveCount(1); - cluster.Components[0].Status.Should().Be(ComponentStatus.Installed); - } - - [Fact] - public void UpdateComponentConfiguration_UpdatesExistingComponentValues() - { - // Arrange — After a configuration change is deployed, we update the - // stored configuration so it reflects the current state. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List results = new() - { - new ComponentCheckResult("monitoring", ComponentStatus.Installed, - new List(), new List(), - new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack", - new Dictionary { ["retention"] = "30d", ["replicas"] = "2" })) - }; - cluster.UpdateComponents(results); - - // Act — Change retention to 90d. - - Dictionary newValues = new() { ["retention"] = "90d" }; - cluster.UpdateComponentConfiguration("monitoring", newValues); - - // Assert — Retention updated, replicas unchanged. - - ClusterComponent monitoring = cluster.Components.First(c => c.ComponentName == "monitoring"); - monitoring.Configuration["retention"].Should().Be("90d"); - monitoring.Configuration["replicas"].Should().Be("2"); - } - - [Fact] - public void UpdateComponentConfiguration_ForUnknownComponent_DoesNotThrow() - { - // Arrange — If a component hasn't been scanned yet, updating its config - // is a no-op. This shouldn't break anything. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - // Act — No components recorded yet. - - Action act = () => cluster.UpdateComponentConfiguration("unknown", new Dictionary { ["k"] = "v" }); - - // Assert - - act.Should().NotThrow(); - cluster.Components.Should().BeEmpty(); - } - - [Fact] - public void UpdateComponentVersion_ForInstalledComponent_UpdatesVersion() - { - // Arrange — A cluster has Harbor installed at version 1.16.0. After a - // successful upgrade, we need to track the new version. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List results = new() - { - new ComponentCheckResult("harbor", ComponentStatus.Installed, - new List { "Running" }, new List(), - new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary())) - }; - - cluster.UpdateComponents(results); - - // Act — Upgrade to 1.17.0. - - cluster.UpdateComponentVersion("harbor", "1.17.0"); - - // Assert — The tracked version now reflects the upgrade. - - ClusterComponent harbor = cluster.Components.First(c => c.ComponentName == "harbor"); - harbor.Version.Should().Be("1.17.0"); - harbor.LastCheckedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - } - - [Fact] - public void UpdateComponentVersion_ForUnknownComponent_DoesNotThrow() - { - // Arrange — If the component hasn't been scanned, updating its version - // is a safe no-op — no exception, no side effects. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - // Act - - Action act = () => cluster.UpdateComponentVersion("nonexistent", "1.0.0"); - - // Assert - - act.Should().NotThrow(); - cluster.Components.Should().BeEmpty(); - } - - [Fact] - public void UpdateComponentVersion_IsCaseInsensitive() - { - // Arrange — Component names should match case-insensitively, just like - // MarkComponentInstalled and UpdateComponentConfiguration. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List results = new() - { - new ComponentCheckResult("Harbor", ComponentStatus.Installed, - new List(), new List(), - new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary())) - }; - - cluster.UpdateComponents(results); - - // Act — Use different casing. - - cluster.UpdateComponentVersion("harbor", "1.17.0"); - - // Assert - - cluster.Components.First().Version.Should().Be("1.17.0"); - } - - [Fact] - public void Register_DefaultsToNoneProvider() - { - // When a cluster is first registered, no cloud provider is set. - // The operator can assign one later when they know where it's hosted. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - cluster.Provider.Should().Be(CloudProvider.None); - cluster.ProviderCredentials.Should().BeNull(); - } - - [Fact] - public void SetProvider_WithCleura_StoresProviderAndCredentials() - { - // A cluster admin links their cluster to Cleura, providing their - // Cleura Cloud username and password so EntKube can call the REST API. - - KubernetesCluster cluster = KubernetesCluster.Register( - "cleura-cluster", "https://k8s.cleura.cloud", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - ProviderCredentials creds = new("my-user", "my-password", "Sto2"); - - // Act - - cluster.SetProvider(CloudProvider.Cleura, creds); - - // Assert - - cluster.Provider.Should().Be(CloudProvider.Cleura); - cluster.ProviderCredentials.Should().NotBeNull(); - cluster.ProviderCredentials!.Username.Should().Be("my-user"); - cluster.ProviderCredentials!.Region.Should().Be("Sto2"); - } - - [Fact] - public void SetProvider_ToNone_ClearsCredentials() - { - // Unlinking a provider removes the stored credentials. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("u", "p", "Sto2")); - - // Act - - cluster.SetProvider(CloudProvider.None, null); - - // Assert - - cluster.Provider.Should().Be(CloudProvider.None); - cluster.ProviderCredentials.Should().BeNull(); - } - - [Fact] - public void SetProvider_CleuraWithoutCredentials_Throws() - { - // Setting Cleura as the provider requires credentials — you can't - // call the Cleura API without a username and password. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - Action act = () => cluster.SetProvider(CloudProvider.Cleura, null); - - act.Should().Throw(); - } -} diff --git a/tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj b/tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj deleted file mode 100644 index 8a171f5..0000000 --- a/tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/EntKube.Clusters.Tests/Features/AdoptClusterHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/AdoptClusterHandlerTests.cs deleted file mode 100644 index 64d2b20..0000000 --- a/tests/EntKube.Clusters.Tests/Features/AdoptClusterHandlerTests.cs +++ /dev/null @@ -1,185 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -public class AdoptClusterHandlerTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock kyvernoCheck; - private readonly Mock securityPoliciesCheck; - private readonly AdoptClusterHandler handler; - - public AdoptClusterHandlerTests() - { - repository = new InMemoryClusterRepository(); - kyvernoCheck = new Mock(); - securityPoliciesCheck = new Mock(); - - kyvernoCheck.Setup(c => c.ComponentName).Returns("kyverno"); - securityPoliciesCheck.Setup(c => c.ComponentName).Returns("security-policies"); - - List checks = new() { kyvernoCheck.Object, securityPoliciesCheck.Object }; - handler = new AdoptClusterHandler(repository, checks, NullLogger.Instance); - } - - [Fact] - public async Task HandleAsync_ClusterNotFound_ReturnsFailure() - { - // Arrange — Attempt to adopt a cluster that doesn't exist. - - Guid unknownId = Guid.NewGuid(); - - // Act - - Result result = await handler.HandleAsync(unknownId); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_ClusterNotConnected_ReturnsFailure() - { - // Arrange — The cluster exists but is still Pending (not yet reachable). - - KubernetesCluster cluster = KubernetesCluster.Register( - "dev-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "dev-ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - // Act - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - [Fact] - public async Task HandleAsync_AllComponentsInstalled_ReturnsReadyReport() - { - // Arrange — A connected cluster where all components report Installed. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Pod running: kyverno-admission-controller-0" }, - new List())); - - securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Installed, - new List { "Policy present: deny-loadbalancer-services" }, - new List())); - - // Act - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert — All components installed means Ready. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Status.Should().Be(AdoptionReadiness.Ready); - result.Value.Components.Should().HaveCount(2); - result.Value.Components.Should().AllSatisfy(c => c.Status.Should().Be(ComponentStatus.Installed)); - } - - [Fact] - public async Task HandleAsync_OneComponentNotInstalled_ReturnsNotReady() - { - // Arrange — Kyverno is missing entirely while policies exist (impossible - // in practice, but tests the aggregation logic). - - KubernetesCluster cluster = KubernetesCluster.Register( - "bare-cluster", "https://k8s.bare:6443", "kubeconfig-data", Guid.NewGuid(), "bare-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.NotInstalled, - new List(), - new List { "No Kyverno pods found" })); - - securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.NotInstalled, - new List(), - new List { "restrict-image-registries", "deny-loadbalancer-services" })); - - // Act - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert — Any component NotInstalled means the cluster is NotReady. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Status.Should().Be(AdoptionReadiness.NotReady); - } - - [Fact] - public async Task HandleAsync_OneComponentDegraded_ReturnsPartiallyReady() - { - // Arrange — Kyverno is installed but security policies are only partially deployed. - - KubernetesCluster cluster = KubernetesCluster.Register( - "partial-cluster", "https://k8s.partial:6443", "kubeconfig-data", Guid.NewGuid(), "partial-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Pod running: kyverno-admission-controller-0" }, - new List())); - - securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Degraded, - new List { "Policy present: deny-loadbalancer-services" }, - new List { "restrict-image-registries", "require-seccomp-runtime-default" })); - - // Act - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert — Degraded components mean PartiallyReady. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Status.Should().Be(AdoptionReadiness.PartiallyReady); - } - - [Fact] - public async Task HandleAsync_RunsAllChecksInParallel() - { - // Arrange — Verify that both checks are called for a connected cluster. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "test-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed, new List(), new List())); - - securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Installed, new List(), new List())); - - // Act - - await handler.HandleAsync(cluster.Id); - - // Assert — Both checks were invoked. - - kyvernoCheck.Verify(c => c.CheckAsync(cluster, It.IsAny()), Times.Once); - securityPoliciesCheck.Verify(c => c.CheckAsync(cluster, It.IsAny()), Times.Once); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/CertificateAuthorityTests.cs b/tests/EntKube.Clusters.Tests/Features/CertificateAuthorityTests.cs deleted file mode 100644 index b85ec3c..0000000 --- a/tests/EntKube.Clusters.Tests/Features/CertificateAuthorityTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using EntKube.Clusters.Features.Certificates; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the Certificate Authority feature. Since the handler connects to -/// a real K8s cluster, these tests focus on the domain models, request validation, -/// and operation result patterns. Integration tests with a real cluster would go -/// in a separate test suite. -/// -public class CertificateAuthorityTests -{ - // ─── CaOperationResult ──────────────────────────────────────────────── - - [Fact] - public void CaOperationResult_Ok_HasSuccessTrue() - { - // When we create a successful result, it should carry the message - // and have Success = true. - - CaOperationResult result = CaOperationResult.Ok("CA created.", new List { "Step 1" }); - - result.Success.Should().BeTrue(); - result.Message.Should().Be("CA created."); - result.Actions.Should().ContainSingle().Which.Should().Be("Step 1"); - } - - [Fact] - public void CaOperationResult_Failure_HasSuccessFalse() - { - // A failure result should carry the error message with Success = false. - - CaOperationResult result = CaOperationResult.Failure("Something went wrong."); - - result.Success.Should().BeFalse(); - result.Message.Should().Be("Something went wrong."); - result.Actions.Should().BeEmpty(); - } - - [Fact] - public void CaOperationResult_Ok_WithNoActions_DefaultsToEmptyList() - { - // When no actions are provided, the list should default to empty. - - CaOperationResult result = CaOperationResult.Ok("Done."); - - result.Actions.Should().NotBeNull(); - result.Actions.Should().BeEmpty(); - } - - // ─── CertificateAuthorityInfo ───────────────────────────────────────── - - [Fact] - public void CertificateAuthorityInfo_InternalCA_HasCorrectType() - { - // An internal CA should be classified as CaType.Internal and have - // no domain restrictions. - - CertificateAuthorityInfo ca = new( - Name: "platform-internal-ca", - Type: CaType.Internal, - SecretName: "platform-internal-ca-secret", - Domains: new List(), - IsExternal: false, - InTrustBundle: true, - Status: "Ready", - NotBefore: DateTimeOffset.UtcNow.AddYears(-1), - NotAfter: DateTimeOffset.UtcNow.AddYears(9), - Organization: "EntKube Platform"); - - ca.Type.Should().Be(CaType.Internal); - ca.Domains.Should().BeEmpty(); - ca.IsExternal.Should().BeFalse(); - ca.InTrustBundle.Should().BeTrue(); - } - - [Fact] - public void CertificateAuthorityInfo_DomainCA_HasDomains() - { - // A domain CA should carry the list of domains it's scoped to. - - CertificateAuthorityInfo ca = new( - Name: "corp-ca", - Type: CaType.Domain, - SecretName: "corp-ca-secret", - Domains: new List { "*.internal.corp.com", "*.svc.local" }, - IsExternal: false, - InTrustBundle: true, - Status: "Ready", - NotBefore: DateTimeOffset.UtcNow, - NotAfter: DateTimeOffset.UtcNow.AddYears(5), - Organization: "Corp Inc"); - - ca.Type.Should().Be(CaType.Domain); - ca.Domains.Should().HaveCount(2); - ca.Domains.Should().Contain("*.internal.corp.com"); - } - - [Fact] - public void CertificateAuthorityInfo_ExternalCA_FlagIsSet() - { - // An imported external CA should have IsExternal = true. - - CertificateAuthorityInfo ca = new( - Name: "digicert-ca", - Type: CaType.Domain, - SecretName: "digicert-ca-secret", - Domains: new List { "*.example.com" }, - IsExternal: true, - InTrustBundle: true, - Status: "Ready", - NotBefore: null, - NotAfter: null, - Organization: "DigiCert"); - - ca.IsExternal.Should().BeTrue(); - } - - [Fact] - public void CertificateAuthorityInfo_LetsEncrypt_HasAcmeType() - { - // A Let's Encrypt issuer is always external and publicly trusted. - - CertificateAuthorityInfo ca = new( - Name: "letsencrypt-prod", - Type: CaType.LetsEncrypt, - SecretName: string.Empty, - Domains: new List(), - IsExternal: true, - InTrustBundle: true, - Status: "Ready", - NotBefore: null, - NotAfter: null, - Organization: "Let's Encrypt"); - - ca.Type.Should().Be(CaType.LetsEncrypt); - ca.IsExternal.Should().BeTrue(); - } - - // ─── Request models ────────────────────────────────────────────────── - - [Fact] - public void CreateInternalCARequest_DefaultValues_AreReasonable() - { - // The default values for an internal CA should be sensible defaults. - - CreateInternalCARequest request = new("my-ca"); - - request.Name.Should().Be("my-ca"); - request.Organization.Should().Be("EntKube Platform"); - request.DurationDays.Should().Be(3650); - request.BundleName.Should().BeNull(); - } - - [Fact] - public void CreateDomainCARequest_WithDomains_CarriesThem() - { - // A domain CA request should carry the domains it covers. - - CreateDomainCARequest request = new( - Name: "corp-ca", - Domains: new List { "*.corp.com", "*.internal.corp.com" }); - - request.Domains.Should().HaveCount(2); - request.TlsCert.Should().BeNull(); - request.TlsKey.Should().BeNull(); - } - - [Fact] - public void CreateDomainCARequest_WithImportedCert_HasTlsFields() - { - // An imported domain CA should carry the base64-encoded cert and key. - - CreateDomainCARequest request = new( - Name: "imported-ca", - Domains: new List { "*.example.com" }, - TlsCert: "base64cert", - TlsKey: "base64key"); - - request.TlsCert.Should().Be("base64cert"); - request.TlsKey.Should().Be("base64key"); - } - - // ─── CaType enum ───────────────────────────────────────────────────── - - [Fact] - public void CaType_HasExpectedValues() - { - // The enum should have exactly three values. - - Enum.GetValues().Should().HaveCount(3); - Enum.GetValues().Should().Contain(CaType.Internal); - Enum.GetValues().Should().Contain(CaType.Domain); - Enum.GetValues().Should().Contain(CaType.LetsEncrypt); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs b/tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs deleted file mode 100644 index 14ce1ce..0000000 --- a/tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using EntKube.Clusters.Infrastructure.Cleura; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class CleuraClientTests -{ - [Fact] - public void ParseAuthResponse_ValidJson_ReturnsToken() - { - // The Cleura API returns a JSON body with result and token fields - // after a successful authentication POST. - - string json = """{"result": "login_ok", "token": "vahkie7EiDaij7chegaitee2zohsh1oh"}"""; - - CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize(json); - - response.Should().NotBeNull(); - response!.Result.Should().Be("login_ok"); - response.Token.Should().Be("vahkie7EiDaij7chegaitee2zohsh1oh"); - } - - [Fact] - public void ParseDomainsResponse_ValidJson_ReturnsRegions() - { - // The domains endpoint returns a list of domains, each with a region identifier. - - string json = """ - { - "domains": [ - {"name": "my-domain", "region": "Sto2", "id": "abc123"}, - {"name": "my-domain", "region": "Fra1", "id": "def456"} - ] - } - """; - - CleuraDomainsResponse? response = System.Text.Json.JsonSerializer.Deserialize(json, - new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - response.Should().NotBeNull(); - response!.Domains.Should().HaveCount(2); - response.Domains[0].Region.Should().Be("Sto2"); - response.Domains[1].Region.Should().Be("Fra1"); - } - - [Fact] - public void BuildAuthRequest_FormatsCorrectly() - { - // Verify the authentication request body matches Cleura's expected format. - - CleuraAuthRequest request = new("my-user", "my-pass"); - - string json = System.Text.Json.JsonSerializer.Serialize(request); - - json.Should().Contain("\"auth\""); - json.Should().Contain("\"login\""); - json.Should().Contain("\"password\""); - json.Should().Contain("my-user"); - } - - [Fact] - public void BuildAuthRequest_WithTwofaMethod_IncludesTwofaMethod() - { - // When a 2FA method is specified, the auth body should include - // the twofa_method field so Cleura selects that method. - - CleuraAuthRequest request = new("my-user", "my-pass", "sms"); - - string json = System.Text.Json.JsonSerializer.Serialize(request); - - json.Should().Contain("\"twofa_method\""); - json.Should().Contain("sms"); - } - - [Fact] - public void BuildAuthRequest_WithoutTwofaMethod_OmitsTwofaMethod() - { - // Without a 2FA method, the twofa_method field should not be present. - - CleuraAuthRequest request = new("my-user", "my-pass"); - - string json = System.Text.Json.JsonSerializer.Serialize(request); - - json.Should().NotContain("twofa_method"); - } - - [Fact] - public void ParseAuthResponse_TwoFactorOptions_IndicatesNeed() - { - // When 2FA is enabled but no code is provided, Cleura responds with - // twofactor_options listing available methods (sms, webauthn). - - string json = """{"result": "twofactor_options", "token": "", "options": ["sms", "webauthn"]}"""; - - CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize(json); - - response.Should().NotBeNull(); - response!.Result.Should().Be("twofactor_options"); - response.Options.Should().Contain("sms"); - response.Options.Should().Contain("webauthn"); - } - - [Fact] - public void ParseAuthResponse_TwoFactorRequired_ReturnsVerification() - { - // When a 2FA method is selected, Cleura responds with twofactor_required - // and a verification token for subsequent steps. - - string json = """{"result": "twofactor_required", "token": "", "verification": "xe9hik8q6r"}"""; - - CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize(json); - - response.Should().NotBeNull(); - response!.Result.Should().Be("twofactor_required"); - response.Verification.Should().Be("xe9hik8q6r"); - } - - [Fact] - public void BuildRequest2faCode_FormatsCorrectly() - { - // The request2facode endpoint expects a specific JSON structure - // with the login and verification token from the prior step. - - CleuraRequest2faCodeRequest request = new("JohnDoe", "xe9hik8q6r"); - - string json = System.Text.Json.JsonSerializer.Serialize(request); - - json.Should().Contain("\"request2fa\""); - json.Should().Contain("\"login\""); - json.Should().Contain("\"verification\""); - json.Should().Contain("JohnDoe"); - json.Should().Contain("xe9hik8q6r"); - } - - [Fact] - public void BuildVerify2fa_FormatsCorrectly() - { - // The verify2fa endpoint expects the login, verification, and integer code. - - CleuraVerify2faRequest request = new("JohnDoe", "xe9hik8q6r", 123456); - - string json = System.Text.Json.JsonSerializer.Serialize(request); - - json.Should().Contain("\"verify2fa\""); - json.Should().Contain("\"login\""); - json.Should().Contain("\"verification\""); - json.Should().Contain("\"code\""); - json.Should().Contain("JohnDoe"); - json.Should().Contain("xe9hik8q6r"); - json.Should().Contain("123456"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/CleuraObjectStorageTests.cs b/tests/EntKube.Clusters.Tests/Features/CleuraObjectStorageTests.cs deleted file mode 100644 index 5c3973e..0000000 --- a/tests/EntKube.Clusters.Tests/Features/CleuraObjectStorageTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using EntKube.Clusters.Infrastructure.Cleura; -using FluentAssertions; -using System.Text.Json; - -namespace EntKube.Clusters.Tests.Features; - -public class CleuraObjectStorageTests -{ - [Fact] - public void ParseKeystoneTokenResponse_ExtractsUserId() - { - // After authenticating with Keystone, the response includes the user ID - // which is needed to create EC2 credentials. - - string json = """ - { - "token": { - "user": { - "id": "abc123", - "name": "testuser", - "domain": { "id": "domainid", "name": "CCP_Domain_1_268" } - }, - "catalog": [], - "expires_at": "2026-05-08T12:00:00Z" - } - } - """; - - KeystoneTokenResponse? response = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - response.Should().NotBeNull(); - response!.Token.User.Id.Should().Be("abc123"); - response.Token.User.Name.Should().Be("testuser"); - } - - [Fact] - public void ParseEc2CredentialResponse_ExtractsAccessAndSecret() - { - // Creating EC2 credentials returns the access/secret key pair - // that serves as S3-compatible credentials. - - string json = """ - { - "credential": { - "access": "AKIAIOSFODNN7EXAMPLE", - "secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "user_id": "abc123", - "project_id": "project456", - "tenant_id": "project456" - } - } - """; - - Ec2CredentialResponse? response = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - response.Should().NotBeNull(); - response!.Credential.Access.Should().Be("AKIAIOSFODNN7EXAMPLE"); - response.Credential.Secret.Should().Be("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); - response.Credential.ProjectId.Should().Be("project456"); - } - - [Fact] - public void S3EndpointUrl_ForRegion_FormatsCorrectly() - { - // Cleura S3 endpoints follow the pattern https://s3-{region}.citycloud.com - - string endpoint = CleuraS3Client.GetS3Endpoint("Sto2"); - - endpoint.Should().Be("https://s3-sto2.citycloud.com"); - } - - [Fact] - public void S3EndpointUrl_ForFra1_FormatsCorrectly() - { - string endpoint = CleuraS3Client.GetS3Endpoint("Fra1"); - - endpoint.Should().Be("https://s3-fra1.citycloud.com"); - } - - [Fact] - public void ParseEc2CredentialListResponse_ReturnsMultiple() - { - // Listing credentials returns all existing EC2 credential pairs for the user/project. - - string json = """ - { - "credentials": [ - { - "access": "KEY1", - "secret": "SECRET1", - "user_id": "u1", - "project_id": "p1", - "tenant_id": "p1" - }, - { - "access": "KEY2", - "secret": "SECRET2", - "user_id": "u1", - "project_id": "p2", - "tenant_id": "p2" - } - ] - } - """; - - Ec2CredentialListResponse? response = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - response.Should().NotBeNull(); - response!.Credentials.Should().HaveCount(2); - response.Credentials[0].Access.Should().Be("KEY1"); - response.Credentials[1].ProjectId.Should().Be("p2"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/ClusterHealthHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/ClusterHealthHandlerTests.cs deleted file mode 100644 index 07878d4..0000000 --- a/tests/EntKube.Clusters.Tests/Features/ClusterHealthHandlerTests.cs +++ /dev/null @@ -1,239 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.ClusterHealth; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class ClusterHealthHandlerTests -{ - private readonly FakePrometheusQueryClient fakeQueryClient; - private readonly FakeClusterRepository repository; - private readonly ClusterHealthHandler sut; - - public ClusterHealthHandlerTests() - { - fakeQueryClient = new FakePrometheusQueryClient(); - repository = new FakeClusterRepository(); - sut = new ClusterHealthHandler(repository, fakeQueryClient); - } - - [Fact] - public async Task HandleAsync_WithHealthyCluster_ReturnsAllMetrics() - { - // Arrange — a cluster with a Prometheus endpoint, all metrics looking good. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "prod", Guid.NewGuid()); - - cluster.SetPrometheusEndpoints(new List - { - new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus") - }); - - await repository.AddAsync(cluster); - - fakeQueryClient.SetResults(new Dictionary - { - ["count(kube_node_info)"] = 3, - ["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 3, - ["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 42.5, - ["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 68.2, - ["sum(kube_pod_status_phase{phase=\"Running\"})"] = 45, - ["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 2, - ["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 0, - ["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 3, - ["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 0.1, - ["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0 - }); - - // Act - - SharedKernel.Domain.Result result = await sut.HandleAsync(cluster.Id); - - // Assert — we get a complete health report. - - result.IsSuccess.Should().BeTrue(); - ClusterHealthReport report = result.Value!; - report.TotalNodes.Should().Be(3); - report.ReadyNodes.Should().Be(3); - report.CpuUtilizationPercent.Should().BeApproximately(42.5, 0.1); - report.MemoryUtilizationPercent.Should().BeApproximately(68.2, 0.1); - report.RunningPods.Should().Be(45); - report.PendingPods.Should().Be(2); - report.FailedPods.Should().Be(0); - report.ContainerRestartsLastHour.Should().Be(3); - report.ApiServerErrorRatePercent.Should().BeApproximately(0.1, 0.01); - report.NodesWithDiskPressure.Should().Be(0); - report.OverallStatus.Should().Be(ClusterHealthStatus.Healthy); - } - - [Fact] - public async Task HandleAsync_WithNoPrometheusEndpoints_ReturnsFailure() - { - // Arrange — cluster without Prometheus discovered yet. - - KubernetesCluster cluster = KubernetesCluster.Register( - "no-prom", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - // Act - - SharedKernel.Domain.Result result = await sut.HandleAsync(cluster.Id); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No Prometheus endpoints"); - } - - [Fact] - public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure() - { - // Act - - SharedKernel.Domain.Result result = await sut.HandleAsync(Guid.NewGuid()); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_WithDegradedMetrics_ReturnsDegradedStatus() - { - // Arrange — high CPU, some pending pods, and container restarts indicate degradation. - - KubernetesCluster cluster = KubernetesCluster.Register( - "degraded-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - cluster.SetPrometheusEndpoints(new List - { - new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus") - }); - - await repository.AddAsync(cluster); - - fakeQueryClient.SetResults(new Dictionary - { - ["count(kube_node_info)"] = 3, - ["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 2, - ["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 85.0, - ["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 78.0, - ["sum(kube_pod_status_phase{phase=\"Running\"})"] = 40, - ["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 8, - ["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 2, - ["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 15, - ["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 3.0, - ["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0 - }); - - // Act - - SharedKernel.Domain.Result result = await sut.HandleAsync(cluster.Id); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Degraded); - } - - [Fact] - public async Task HandleAsync_WithCriticalMetrics_ReturnsCriticalStatus() - { - // Arrange — nodes not ready and high API error rate = critical. - - KubernetesCluster cluster = KubernetesCluster.Register( - "critical-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - cluster.SetPrometheusEndpoints(new List - { - new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus") - }); - - await repository.AddAsync(cluster); - - fakeQueryClient.SetResults(new Dictionary - { - ["count(kube_node_info)"] = 3, - ["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 1, - ["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 95.0, - ["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 92.0, - ["sum(kube_pod_status_phase{phase=\"Running\"})"] = 10, - ["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 20, - ["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 15, - ["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 50, - ["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 12.0, - ["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 2 - }); - - // Act - - SharedKernel.Domain.Result result = await sut.HandleAsync(cluster.Id); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Critical); - } -} - -/// -/// Fake Prometheus query client for testing — returns preconfigured values -/// for specific PromQL queries without needing a real Prometheus instance. -/// -public class FakePrometheusQueryClient : IPrometheusQueryClient -{ - private Dictionary results = new(); - - public void SetResults(Dictionary queryResults) - { - results = queryResults; - } - - public Task QueryScalarAsync(KubernetesCluster cluster, string prometheusUrl, string promql, CancellationToken ct = default) - { - if (results.TryGetValue(promql, out double value)) - { - return Task.FromResult(value); - } - - return Task.FromResult(null); - } -} - -/// -/// Simple fake repository for test isolation. -/// -public class FakeClusterRepository : IClusterRepository -{ - private readonly List clusters = new(); - - public Task GetByIdAsync(Guid id, CancellationToken ct = default) - { - return Task.FromResult(clusters.FirstOrDefault(c => c.Id == id)); - } - - public Task> GetAllAsync(CancellationToken ct = default) - { - return Task.FromResult>(clusters.AsReadOnly()); - } - - public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - clusters.Add(cluster); - return Task.CompletedTask; - } - - public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - return Task.CompletedTask; - } - - public Task DeleteAsync(Guid id, CancellationToken ct = default) - { - clusters.RemoveAll(c => c.Id == id); - return Task.CompletedTask; - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs b/tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs deleted file mode 100644 index e0a0942..0000000 --- a/tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.ClusterSettings; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Cluster settings let operators view and edit configuration that lives on the -/// live cluster — like the list of allowed container registries in the Kyverno -/// restrict-image-registries policy. These tests verify the handler logic without -/// touching a real cluster. The actual Kubernetes interaction is behind an -/// IClusterSettingsReader abstraction so we can mock it in tests. -/// -public class ClusterSettingsTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock settingsReader; - private readonly Mock settingsWriter; - private readonly GetClusterSettingsHandler getHandler; - private readonly UpdateClusterSettingsHandler updateHandler; - - public ClusterSettingsTests() - { - repository = new InMemoryClusterRepository(); - settingsReader = new Mock(); - settingsWriter = new Mock(); - getHandler = new GetClusterSettingsHandler(repository, settingsReader.Object); - updateHandler = new UpdateClusterSettingsHandler(repository, settingsWriter.Object); - } - - // ─── GET ─────────────────────────────────────────────────────────────── - - [Fact] - public async Task GetSettings_WithConnectedCluster_ReturnsSettings() - { - // Arrange — a connected cluster with some registries configured. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - List registries = new() { "docker.io", "ghcr.io", "registry.k8s.io" }; - settingsReader - .Setup(r => r.ReadAllowedRegistriesAsync(cluster, It.IsAny())) - .ReturnsAsync(registries); - - // Act - - Result result = await getHandler.HandleAsync(cluster.Id); - - // Assert — the returned settings should include the registries from the live cluster. - - result.IsSuccess.Should().BeTrue(); - result.Value!.AllowedRegistries.Should().BeEquivalentTo(registries); - } - - [Fact] - public async Task GetSettings_WithNonExistentCluster_ReturnsFailure() - { - // Act - - Result result = await getHandler.HandleAsync(Guid.NewGuid()); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task GetSettings_WithDisconnectedCluster_ReturnsFailure() - { - // Arrange — a cluster that hasn't been connected yet. - - KubernetesCluster cluster = KubernetesCluster.Register( - "pending", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - // Act - - Result result = await getHandler.HandleAsync(cluster.Id); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - // ─── UPDATE ──────────────────────────────────────────────────────────── - - [Fact] - public async Task UpdateSettings_WithValidRegistries_CallsWriter() - { - // Arrange — a connected cluster. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - List newRegistries = new() { "docker.io", "ghcr.io", "myregistry.com" }; - settingsWriter - .Setup(w => w.UpdateAllowedRegistriesAsync(cluster, newRegistries, It.IsAny())) - .ReturnsAsync(Result.Success()); - - UpdateClusterSettingsRequest request = new(AllowedRegistries: newRegistries); - - // Act - - Result result = await updateHandler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - settingsWriter.Verify( - w => w.UpdateAllowedRegistriesAsync(cluster, newRegistries, It.IsAny()), - Times.Once); - } - - [Fact] - public async Task UpdateSettings_WithEmptyRegistries_ReturnsFailure() - { - // Arrange - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - UpdateClusterSettingsRequest request = new(AllowedRegistries: new List()); - - // Act - - Result result = await updateHandler.HandleAsync(cluster.Id, request); - - // Assert — can't have zero allowed registries, that would block all pods. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("at least one"); - } - - [Fact] - public async Task UpdateSettings_WithNonExistentCluster_ReturnsFailure() - { - // Arrange - - UpdateClusterSettingsRequest request = new(AllowedRegistries: new List { "docker.io" }); - - // Act - - Result result = await updateHandler.HandleAsync(Guid.NewGuid(), request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task UpdateSettings_WithDisconnectedCluster_ReturnsFailure() - { - // Arrange - - KubernetesCluster cluster = KubernetesCluster.Register( - "pending", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - UpdateClusterSettingsRequest request = new(AllowedRegistries: new List { "docker.io" }); - - // Act - - Result result = await updateHandler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/CnpgDatabaseProvisionerTests.cs b/tests/EntKube.Clusters.Tests/Features/CnpgDatabaseProvisionerTests.cs deleted file mode 100644 index 4aa369a..0000000 --- a/tests/EntKube.Clusters.Tests/Features/CnpgDatabaseProvisionerTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using EntKube.Clusters.Features.AdoptCluster.Components; -using FluentAssertions; -using k8s.Models; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the shared CNPG database provisioner that builds Job and Secret -/// manifests for creating databases on a CNPG cluster. These are pure manifest -/// builders — no Kubernetes API calls — so they're fully unit-testable. -/// -public class CnpgDatabaseProvisionerTests -{ - [Fact] - public void GetPrimaryHost_ReturnsCorrectFqdn() - { - // The CNPG primary service follows the naming convention - // {clusterName}-rw.{namespace}.svc.cluster.local. - - string host = CnpgDatabaseProvisioner.GetPrimaryHost("shared-pg", "cnpg-system"); - - host.Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local"); - } - - [Fact] - public void BuildCreateDatabaseJob_TargetsPrimaryService() - { - // The Job should connect to the CNPG cluster's read-write primary - // service to execute the CREATE ROLE and CREATE DATABASE commands. - - V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - "shared-pg", "cnpg-system", "gitea", "gitea", "secret123"); - - // The psql command should target {clusterName}-rw. - - string args = job.Spec.Template.Spec.Containers[0].Args[0]; - args.Should().Contain("psql -h shared-pg-rw"); - } - - [Fact] - public void BuildCreateDatabaseJob_UsesSuperuserSecret() - { - // The Job needs the superuser password from the CNPG cluster's - // automatically created {clusterName}-superuser secret. - - V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - "shared-pg", "cnpg-system", "gitea", "gitea", "secret123"); - - V1EnvVar passwordEnv = job.Spec.Template.Spec.Containers[0].Env[0]; - passwordEnv.Name.Should().Be("POSTGRES_PASSWORD"); - passwordEnv.ValueFrom!.SecretKeyRef!.Name.Should().Be("shared-pg-superuser"); - passwordEnv.ValueFrom.SecretKeyRef.Key.Should().Be("password"); - } - - [Fact] - public void BuildCreateDatabaseJob_SetsCorrectNamespace() - { - // The Job must run in the same namespace as the CNPG cluster - // so it can resolve the primary service by short name. - - V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - "shared-pg", "cnpg-system", "harbor", "harbor", "pass"); - - job.Metadata.NamespaceProperty.Should().Be("cnpg-system"); - } - - [Fact] - public void BuildCreateDatabaseJob_HasManagementLabels() - { - // The Job should be labeled for EntKube management and discoverability. - - V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - "shared-pg", "cnpg-system", "keycloak", "keycloak", "pass"); - - job.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube"); - job.Metadata.Labels["entkube.io/operation"].Should().Be("create-database"); - } - - [Fact] - public void BuildCreateDatabaseJob_UsesIdempotentSql() - { - // The SQL script should use IF NOT EXISTS patterns so re-running - // the Job on an already-provisioned database doesn't fail. - - V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob( - "shared-pg", "cnpg-system", "gitea", "gitea", "secret123"); - - string args = job.Spec.Template.Spec.Containers[0].Args[0]; - args.Should().Contain("WHERE NOT EXISTS (SELECT FROM pg_roles"); - args.Should().Contain("WHERE NOT EXISTS (SELECT FROM pg_database"); - } - - [Fact] - public void BuildCredentialsSecret_ContainsAllConnectionDetails() - { - // The credentials secret should contain host, database, username, - // and password so the consuming service can connect. - - V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - "gitea-db-credentials", "gitea", - "shared-pg-rw.cnpg-system.svc.cluster.local", - "gitea", "gitea", "secret123"); - - secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local"); - secret.StringData["database"].Should().Be("gitea"); - secret.StringData["username"].Should().Be("gitea"); - secret.StringData["password"].Should().Be("secret123"); - } - - [Fact] - public void BuildCredentialsSecret_HasManagementLabels() - { - // The secret should be labeled for EntKube management. - - V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - "harbor-db-credentials", "harbor", - "shared-pg-rw.cnpg-system.svc.cluster.local", - "harbor", "harbor", "pass"); - - secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube"); - secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials"); - } - - [Fact] - public void BuildCredentialsSecret_SetsCorrectNamespace() - { - // The secret must be created in the target service's namespace, - // not the CNPG cluster's namespace. - - V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - "keycloak-db-credentials", "keycloak", - "shared-pg-rw.cnpg-system.svc.cluster.local", - "keycloak", "keycloak", "pass"); - - secret.Metadata.NamespaceProperty.Should().Be("keycloak"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/ComponentPersistenceTests.cs b/tests/EntKube.Clusters.Tests/Features/ComponentPersistenceTests.cs deleted file mode 100644 index b919df0..0000000 --- a/tests/EntKube.Clusters.Tests/Features/ComponentPersistenceTests.cs +++ /dev/null @@ -1,237 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Verifies that adoption scans, deployments, and configuration changes -/// persist component state on the cluster aggregate. This ensures we don't -/// need to re-scan the cluster every time the UI loads — detected components -/// are stored and updated incrementally. -/// -public class ComponentPersistenceTests -{ - private readonly InMemoryClusterRepository repository; - - public ComponentPersistenceTests() - { - repository = new InMemoryClusterRepository(); - } - - [Fact] - public async Task AdoptCluster_PersistsDetectedComponentsOnCluster() - { - // Arrange — A connected cluster with two components: one installed, one missing. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - Mock kyvernoCheck = new(); - kyvernoCheck.Setup(c => c.ComponentName).Returns("kyverno"); - kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Pod running" }, new List(), - new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", - new Dictionary { ["replicas"] = "3" }))); - - Mock certManagerCheck = new(); - certManagerCheck.Setup(c => c.ComponentName).Returns("cert-manager"); - certManagerCheck.Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled, - new List(), new List { "No pods found" })); - - List checks = new() { kyvernoCheck.Object, certManagerCheck.Object }; - AdoptClusterHandler handler = new(repository, checks, NullLogger.Instance); - - // Act — Run the adoption scan. - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert — Components should now be persisted on the cluster. - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id); - persisted!.Components.Should().HaveCount(2); - - ClusterComponent kyverno = persisted.Components.First(c => c.ComponentName == "kyverno"); - kyverno.Status.Should().Be(ComponentStatus.Installed); - kyverno.Version.Should().Be("3.3.4"); - kyverno.Configuration["replicas"].Should().Be("3"); - - ClusterComponent certManager = persisted.Components.First(c => c.ComponentName == "cert-manager"); - certManager.Status.Should().Be(ComponentStatus.NotInstalled); - } - - [Fact] - public async Task ConfigureComponent_UpdatesPersistedConfiguration() - { - // Arrange — A cluster with monitoring already detected and persisted. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - cluster.UpdateComponents(new List - { - new("monitoring", ComponentStatus.Installed, new List(), new List(), - new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack", - new Dictionary { ["retention"] = "30d" })) - }); - await repository.AddAsync(cluster); - - Mock monitoringInstaller = new(); - monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring"); - monitoringInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "monitoring", "Reconfigured", new List { "retention=90d" })); - - List installers = new() { monitoringInstaller.Object }; - ConfigureComponentHandler handler = new(repository, installers); - - ConfigureComponentRequest request = new("monitoring", Values: new Dictionary - { - ["retention"] = "90d" - }); - - // Act — Apply the configuration change. - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — The persisted component configuration should reflect the new value. - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id); - ClusterComponent monitoring = persisted!.Components.First(c => c.ComponentName == "monitoring"); - monitoring.Configuration["retention"].Should().Be("90d"); - } - - [Fact] - public async Task DeployComponent_UpdatesPersistedComponentStatus() - { - // Arrange — A cluster with cert-manager detected as NotInstalled. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - cluster.UpdateComponents(new List - { - new("cert-manager", ComponentStatus.NotInstalled, new List(), new List { "Missing" }) - }); - await repository.AddAsync(cluster); - - Mock certManagerInstaller = new(); - certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager"); - certManagerInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cert-manager", "Installed v1.14.0", - new List { "Helm install cert-manager v1.14.0" })); - - List installers = new() { certManagerInstaller.Object }; - DeployComponentHandler handler = new(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("cert-manager", Version: "1.14.0", Namespace: "cert-manager"); - - // Act — Deploy the component. - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — The persisted component should now show Installed. - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id); - ClusterComponent certManager = persisted!.Components.First(c => c.ComponentName == "cert-manager"); - certManager.Status.Should().Be(ComponentStatus.Installed); - } - - [Fact] - public async Task ConfigureComponent_WhenInstallerFails_DoesNotUpdatePersisted() - { - // Arrange — Configuration that fails should NOT update the persisted state. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - cluster.UpdateComponents(new List - { - new("monitoring", ComponentStatus.Installed, new List(), new List(), - new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack", - new Dictionary { ["retention"] = "30d" })) - }); - await repository.AddAsync(cluster); - - Mock monitoringInstaller = new(); - monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring"); - monitoringInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(false, "monitoring", "Helm failed", new List())); - - List installers = new() { monitoringInstaller.Object }; - ConfigureComponentHandler handler = new(repository, installers); - - ConfigureComponentRequest request = new("monitoring", Values: new Dictionary - { - ["retention"] = "90d" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Original value preserved. - - result.IsFailure.Should().BeTrue(); - - KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id); - ClusterComponent monitoring = persisted!.Components.First(c => c.ComponentName == "monitoring"); - monitoring.Configuration["retention"].Should().Be("30d"); - } - - [Fact] - public async Task DeployComponent_WhenInstallerFails_DoesNotUpdatePersisted() - { - // Arrange — Failed deployment should not change persisted status. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - cluster.UpdateComponents(new List - { - new("cert-manager", ComponentStatus.NotInstalled, new List(), new List()) - }); - await repository.AddAsync(cluster); - - Mock installer = new(); - installer.Setup(i => i.ComponentName).Returns("cert-manager"); - installer - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(false, "cert-manager", "Timeout", new List())); - - List installers = new() { installer.Object }; - DeployComponentHandler handler = new(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("cert-manager"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - - KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id); - ClusterComponent certManager = persisted!.Components.First(c => c.ComponentName == "cert-manager"); - certManager.Status.Should().Be(ComponentStatus.NotInstalled); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/ConfigurationSchemaTests.cs b/tests/EntKube.Clusters.Tests/Features/ConfigurationSchemaTests.cs deleted file mode 100644 index 8597c4a..0000000 --- a/tests/EntKube.Clusters.Tests/Features/ConfigurationSchemaTests.cs +++ /dev/null @@ -1,634 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the component configuration schema system. Schemas define what -/// parameters each component accepts, their types, validation constraints, -/// and how they're grouped in the UI. This enables the frontend to render -/// proper typed forms instead of raw key/value editors. -/// -public class ConfigurationSchemaTests -{ - [Fact] - public void ConfigurationParameter_WithRequiredString_ValidatesPresence() - { - // Arrange — A required parameter with no value provided. - - ConfigurationParameter param = new( - Key: "email", - DisplayName: "Email Address", - Description: "The email registered with the certificate authority", - Type: ParameterType.String, - Group: "General", - IsRequired: true); - - // Act - - ParameterValidationResult result = param.Validate(null); - - // Assert - - result.IsValid.Should().BeFalse(); - result.Error.Should().Contain("required"); - } - - [Fact] - public void ConfigurationParameter_WithRequiredString_PassesWhenProvided() - { - // Arrange - - ConfigurationParameter param = new( - Key: "email", - DisplayName: "Email Address", - Description: "The email registered with the certificate authority", - Type: ParameterType.String, - Group: "General", - IsRequired: true); - - // Act - - ParameterValidationResult result = param.Validate("admin@example.com"); - - // Assert - - result.IsValid.Should().BeTrue(); - } - - [Fact] - public void ConfigurationParameter_WithIntegerType_RejectsNonNumeric() - { - // Arrange — An integer parameter with a non-numeric value. - - ConfigurationParameter param = new( - Key: "replicas", - DisplayName: "Replicas", - Description: "Number of pod replicas", - Type: ParameterType.Integer, - Group: "Scaling", - IsRequired: true, - Min: 1, - Max: 10); - - // Act - - ParameterValidationResult result = param.Validate("not-a-number"); - - // Assert - - result.IsValid.Should().BeFalse(); - result.Error.Should().Contain("integer"); - } - - [Fact] - public void ConfigurationParameter_WithIntegerType_RejectsOutOfRange() - { - // Arrange — An integer outside the allowed range. - - ConfigurationParameter param = new( - Key: "replicas", - DisplayName: "Replicas", - Description: "Number of pod replicas", - Type: ParameterType.Integer, - Group: "Scaling", - IsRequired: true, - Min: 1, - Max: 10); - - // Act - - ParameterValidationResult result = param.Validate("15"); - - // Assert - - result.IsValid.Should().BeFalse(); - result.Error.Should().Contain("10"); - } - - [Fact] - public void ConfigurationParameter_WithIntegerType_AcceptsValidValue() - { - // Arrange - - ConfigurationParameter param = new( - Key: "replicas", - DisplayName: "Replicas", - Description: "Number of pod replicas", - Type: ParameterType.Integer, - Group: "Scaling", - IsRequired: true, - Min: 1, - Max: 10); - - // Act - - ParameterValidationResult result = param.Validate("3"); - - // Assert - - result.IsValid.Should().BeTrue(); - } - - [Fact] - public void ConfigurationParameter_WithBooleanType_RejectsInvalidValues() - { - // Arrange — A boolean parameter with a non-boolean value. - - ConfigurationParameter param = new( - Key: "grafanaEnabled", - DisplayName: "Enable Grafana", - Description: "Whether to deploy Grafana alongside Prometheus", - Type: ParameterType.Boolean, - Group: "Features"); - - // Act - - ParameterValidationResult result = param.Validate("maybe"); - - // Assert - - result.IsValid.Should().BeFalse(); - result.Error.Should().Contain("true"); - } - - [Fact] - public void ConfigurationParameter_WithBooleanType_AcceptsTrueAndFalse() - { - // Arrange - - ConfigurationParameter param = new( - Key: "grafanaEnabled", - DisplayName: "Enable Grafana", - Description: "Whether to deploy Grafana alongside Prometheus", - Type: ParameterType.Boolean, - Group: "Features"); - - // Act & Assert - - param.Validate("true").IsValid.Should().BeTrue(); - param.Validate("false").IsValid.Should().BeTrue(); - } - - [Fact] - public void ConfigurationParameter_WithSelectType_RejectsUnknownOption() - { - // Arrange — A select parameter with allowed values. - - ConfigurationParameter param = new( - Key: "provider", - DisplayName: "Ingress Provider", - Description: "Which ingress controller to use", - Type: ParameterType.Select, - Group: "General", - IsRequired: true, - AllowedValues: new List { "traefik", "istio" }); - - // Act - - ParameterValidationResult result = param.Validate("nginx"); - - // Assert - - result.IsValid.Should().BeFalse(); - result.Error.Should().Contain("traefik"); - } - - [Fact] - public void ConfigurationParameter_WithSelectType_AcceptsAllowedValue() - { - // Arrange - - ConfigurationParameter param = new( - Key: "provider", - DisplayName: "Ingress Provider", - Description: "Which ingress controller to use", - Type: ParameterType.Select, - Group: "General", - IsRequired: true, - AllowedValues: new List { "traefik", "istio" }); - - // Act - - ParameterValidationResult result = param.Validate("istio"); - - // Assert - - result.IsValid.Should().BeTrue(); - } - - [Fact] - public void ConfigurationParameter_Optional_AcceptsNullOrEmpty() - { - // Arrange — An optional parameter with no value is valid. - - ConfigurationParameter param = new( - Key: "storageSize", - DisplayName: "Storage Size", - Description: "PVC storage size", - Type: ParameterType.String, - Group: "Storage", - IsRequired: false, - DefaultValue: "50Gi"); - - // Act & Assert - - param.Validate(null).IsValid.Should().BeTrue(); - param.Validate("").IsValid.Should().BeTrue(); - } - - [Fact] - public void ConfigurationSchema_ValidateAll_ReturnsAllErrors() - { - // Arrange — A schema with multiple parameters, some invalid. - - ConfigurationSchema schema = new( - ComponentName: "monitoring", - DisplayName: "Monitoring (kube-prometheus-stack)", - Description: "Prometheus, Grafana, and Alertmanager stack", - Parameters: new List - { - new(Key: "retention", DisplayName: "Retention", Description: "Data retention period", - Type: ParameterType.String, Group: "Storage", IsRequired: true), - new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas", - Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10), - new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana", - Type: ParameterType.Boolean, Group: "Features") - }); - - Dictionary values = new() - { - ["replicas"] = "999", - ["grafanaEnabled"] = "invalid" - }; - - // Act - - SchemaValidationResult result = schema.Validate(values); - - // Assert — Three errors: retention missing, replicas out of range, grafana invalid. - - result.IsValid.Should().BeFalse(); - result.Errors.Should().HaveCount(3); - result.Errors.Should().ContainKey("retention"); - result.Errors.Should().ContainKey("replicas"); - result.Errors.Should().ContainKey("grafanaEnabled"); - } - - [Fact] - public void ConfigurationSchema_ValidateAll_PassesWithValidValues() - { - // Arrange - - ConfigurationSchema schema = new( - ComponentName: "monitoring", - DisplayName: "Monitoring (kube-prometheus-stack)", - Description: "Prometheus, Grafana, and Alertmanager stack", - Parameters: new List - { - new(Key: "retention", DisplayName: "Retention", Description: "Data retention period", - Type: ParameterType.String, Group: "Storage", IsRequired: true), - new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas", - Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10) - }); - - Dictionary values = new() - { - ["retention"] = "30d", - ["replicas"] = "2" - }; - - // Act - - SchemaValidationResult result = schema.Validate(values); - - // Assert - - result.IsValid.Should().BeTrue(); - result.Errors.Should().BeEmpty(); - } - - [Fact] - public void ConfigurationSchema_GroupsParameters_ByGroupName() - { - // Arrange — Schema parameters can be grouped for UI rendering. - - ConfigurationSchema schema = new( - ComponentName: "monitoring", - DisplayName: "Monitoring", - Description: "Prometheus stack", - Parameters: new List - { - new(Key: "retention", DisplayName: "Retention", Description: "Retention", - Type: ParameterType.String, Group: "Storage"), - new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size", - Type: ParameterType.String, Group: "Storage"), - new(Key: "replicas", DisplayName: "Replicas", Description: "Replicas", - Type: ParameterType.Integer, Group: "Scaling", Min: 1, Max: 10), - new(Key: "grafanaEnabled", DisplayName: "Grafana", Description: "Enable Grafana", - Type: ParameterType.Boolean, Group: "Features") - }); - - // Act - - Dictionary> groups = schema.GetParametersByGroup(); - - // Assert - - groups.Should().HaveCount(3); - groups["Storage"].Should().HaveCount(2); - groups["Scaling"].Should().HaveCount(1); - groups["Features"].Should().HaveCount(1); - } - - [Fact] - public void CertManagerSchema_DoesNotContainLetsEncryptParameters() - { - // Arrange & Act — The cert-manager schema should only have scaling - // parameters. Let's Encrypt configuration belongs to the letsencrypt component. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager"); - - // Assert — No Let's Encrypt-related keys should exist. - - List paramKeys = schema.Parameters.Select(p => p.Key).ToList(); - paramKeys.Should().NotContain("letsEncryptEmail"); - paramKeys.Should().NotContain("httpSolverEnabled"); - paramKeys.Should().NotContain("dnsSolverEnabled"); - paramKeys.Should().NotContain("dnsSolverProvider"); - paramKeys.Should().Contain("replicas"); - } - - [Fact] - public void LetsEncryptSchema_ContainsSolverAndEmailParameters() - { - // Arrange & Act — The letsencrypt schema should own all the ACME/solver - // configuration that was previously in cert-manager. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("letsencrypt"); - - // Assert - - List paramKeys = schema.Parameters.Select(p => p.Key).ToList(); - paramKeys.Should().Contain("email"); - paramKeys.Should().Contain("httpSolverEnabled"); - paramKeys.Should().Contain("dnsSolverEnabled"); - paramKeys.Should().Contain("dnsSolverProvider"); - schema.Parameters.First(p => p.Key == "email").IsRequired.Should().BeTrue(); - } - - [Fact] - public void CertManagerSchema_OnlyHasScalingGroup() - { - // The cert-manager component is purely the runtime engine — its schema - // should only expose scaling settings, not TLS/issuer configuration. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager"); - - Dictionary> groups = schema.GetParametersByGroup(); - - groups.Should().HaveCount(1); - groups.Keys.Should().Contain("Scaling"); - } - - // ─── Contextual Schema Enrichment ───────────────────────────────────── - - [Fact] - public void EnrichForCluster_WithIstioIngress_DefaultsToGatewayHTTPRoute() - { - // Arrange — A cluster where the ingress component reports Istio as the - // provider, with an internal gateway detected. When configuring Let's - // Encrypt on this cluster, the HTTP-01 solver should default to - // gatewayHTTPRoute mode with the internal gateway pre-selected. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "istio", - ["hasInternalGateway"] = "True", - ["hasExternalGateway"] = "True" - }) - }; - - // Act — Enrich the letsencrypt schema using the cluster's component state. - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); - - // Assert — The HTTP-01 solver should default to Gateway API mode. - - ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode"); - modeParam.DefaultValue.Should().Be("gatewayHTTPRoute"); - - ConfigurationParameter gatewayNameParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName"); - gatewayNameParam.DefaultValue.Should().Be("internal"); - - ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace"); - gatewayNsParam.DefaultValue.Should().Be("internal-ingress"); - } - - [Fact] - public void EnrichForCluster_WithTraefikIngress_DefaultsToIngressMode() - { - // Arrange — A cluster using Traefik for ingress. The HTTP-01 solver - // should default to ingress mode with "traefik" as the class. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "traefik", - ["ingressClassRegistered"] = "True" - }) - }; - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); - - // Assert — Should remain in ingress mode with traefik class. - - ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode"); - modeParam.DefaultValue.Should().Be("ingress"); - - ConfigurationParameter classParam = enriched.Parameters.First(p => p.Key == "httpSolverIngressClass"); - classParam.DefaultValue.Should().Be("traefik"); - } - - [Fact] - public void EnrichForCluster_WithNoIngressComponent_UsesStaticDefaults() - { - // Arrange — No ingress component detected on the cluster. The schema - // should return unchanged static defaults. - - List components = new(); - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); - - // Assert — Static defaults should be preserved. - - ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode"); - modeParam.DefaultValue.Should().Be("ingress"); - } - - [Fact] - public void EnrichForCluster_WithIstioAndDiscoveredGateways_SetsGatewayAsSelect() - { - // Arrange — A cluster with Istio and discovered gateways stored in - // the ingress component's config. The httpSolverGatewayName should - // become a Select parameter with the available gateways as options. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "istio", - ["hasInternalGateway"] = "True", - ["hasExternalGateway"] = "True" - }), - BuildComponent("letsencrypt", ComponentStatus.Installed, new Dictionary - { - ["availableGateways"] = "[{\"name\":\"internal\",\"namespace\":\"internal-ingress\"},{\"name\":\"external\",\"namespace\":\"external-ingress\"}]" - }) - }; - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); - - // Assert — Gateway name should be a Select with discovered gateways as options. - - ConfigurationParameter gatewayParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName"); - gatewayParam.Type.Should().Be(ParameterType.Select); - gatewayParam.AllowedValues.Should().Contain("internal"); - gatewayParam.AllowedValues.Should().Contain("external"); - } - - [Fact] - public void EnrichForCluster_NonLetsEncryptComponent_ReturnsUnchanged() - { - // Arrange — Enriching a component other than letsencrypt should - // return the static schema unchanged. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "istio" - }) - }; - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("cert-manager", components); - - // Assert — cert-manager schema should be unchanged. - - ConfigurationSchema original = ComponentSchemas.GetSchema("cert-manager"); - enriched.Parameters.Should().HaveCount(original.Parameters.Count); - } - - [Fact] - public void EnrichForCluster_WithIstioAndExistingLetsEncryptConfig_PreservesDiscoveredValues() - { - // Arrange — A cluster where Let's Encrypt was already configured with - // specific values. The enrichment should set defaults from the ingress - // provider but the existing discovered config (email, solver type) is - // separate and handled by the UI pre-fill, not schema defaults. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "istio", - ["hasInternalGateway"] = "True", - ["hasExternalGateway"] = "False" - }) - }; - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components); - - // Assert — With only internal gateway, external should not appear in options. - // The gateway namespace should default to internal-ingress. - - ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace"); - gatewayNsParam.DefaultValue.Should().Be("internal-ingress"); - } - - [Fact] - public void EnrichForCluster_IngressWithExternalGateway_DefaultsEnableExternalToTrue() - { - // Arrange — The cluster scan detected an external gateway. When the - // user opens the ingress configuration, the enable_external toggle - // should default to true so it reflects reality. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "istio", - ["hasExternalGateway"] = "True" - }) - }; - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components); - - // Assert — enable_external should default to "true". - - ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external"); - extParam.DefaultValue.Should().Be("true"); - } - - [Fact] - public void EnrichForCluster_IngressWithoutExternalGateway_DefaultsEnableExternalToFalse() - { - // Arrange — No external gateway detected. The default should stay false. - - List components = new() - { - BuildComponent("ingress", ComponentStatus.Installed, new Dictionary - { - ["provider"] = "istio", - ["hasExternalGateway"] = "False" - }) - }; - - // Act - - ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components); - - // Assert — enable_external should remain "false". - - ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external"); - extParam.DefaultValue.Should().Be("false"); - } - - // ─── Helpers ────────────────────────────────────────────────────────── - - private static ClusterComponent BuildComponent( - string name, ComponentStatus status, Dictionary config) - { - ComponentCheckResult checkResult = new( - name, status, new List(), new List(), - new DiscoveredConfiguration(null, null, null, config)); - - return ClusterComponent.FromCheckResult(checkResult); - } - - [Fact] - public void ParameterType_IncludesPostgresCluster() - { - // The UI needs a PostgresCluster parameter type to render a CNPG cluster - // picker, similar to how StorageBucket renders a bucket picker. - - ParameterType type = ParameterType.PostgresCluster; - type.Should().BeDefined(); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/ConfigureComponentHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/ConfigureComponentHandlerTests.cs deleted file mode 100644 index c349bb7..0000000 --- a/tests/EntKube.Clusters.Tests/Features/ConfigureComponentHandlerTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -public class ConfigureComponentHandlerTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock monitoringInstaller; - private readonly Mock kyvernoInstaller; - private readonly ConfigureComponentHandler handler; - - public ConfigureComponentHandlerTests() - { - repository = new InMemoryClusterRepository(); - - monitoringInstaller = new Mock(); - monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring"); - - kyvernoInstaller = new Mock(); - kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno"); - - List installers = new() { monitoringInstaller.Object, kyvernoInstaller.Object }; - handler = new ConfigureComponentHandler(repository, installers); - } - - [Fact] - public async Task HandleAsync_WithValidComponent_CallsConfigureAsync() - { - // Arrange — A connected cluster and a request to change Prometheus retention. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - monitoringInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "monitoring", "Reconfigured", new List { "retention=90d" })); - - ConfigureComponentRequest request = new("monitoring", Values: new Dictionary - { - ["retention"] = "90d" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.Success.Should().BeTrue(); - result.Value.Message.Should().Be("Reconfigured"); - - monitoringInstaller.Verify( - i => i.ConfigureAsync(cluster, It.Is(c => c.Values["retention"] == "90d"), It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure() - { - // Arrange - - ConfigureComponentRequest request = new("monitoring", Values: new Dictionary - { - ["retention"] = "90d" - }); - - // Act - - Result result = await handler.HandleAsync(Guid.NewGuid(), request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_WithDisconnectedCluster_ReturnsFailure() - { - // Arrange — Cluster that isn't connected can't be configured. - - KubernetesCluster cluster = KubernetesCluster.Register( - "offline-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - ConfigureComponentRequest request = new("monitoring", Values: new Dictionary - { - ["retention"] = "90d" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - [Fact] - public async Task HandleAsync_WithUnknownComponent_ReturnsFailure() - { - // Arrange — Request an installer that doesn't exist. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - ConfigureComponentRequest request = new("nonexistent-thing", Values: new Dictionary - { - ["foo"] = "bar" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No installer found"); - result.Error.Should().Contain("monitoring"); - result.Error.Should().Contain("kyverno"); - } - - [Fact] - public async Task HandleAsync_WhenConfigureFails_ReturnsFailure() - { - // Arrange — The installer reports a failure. - - KubernetesCluster cluster = KubernetesCluster.Register( - "broken-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(false, "kyverno", "Helm upgrade failed: timeout", new List { "Error: timeout" })); - - ConfigureComponentRequest request = new("kyverno", Values: new Dictionary - { - ["admissionReplicas"] = "4" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("Helm upgrade failed"); - } - - [Fact] - public async Task HandleAsync_PassesNamespaceToConfiguration() - { - // Arrange — Verify the namespace from the request reaches the installer. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - monitoringInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "monitoring", "Done", new List())); - - ConfigureComponentRequest request = new("monitoring", Namespace: "custom-monitoring", Values: new Dictionary - { - ["retention"] = "7d" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - monitoringInstaller.Verify( - i => i.ConfigureAsync(cluster, It.Is(c => c.Namespace == "custom-monitoring"), It.IsAny()), - Times.Once); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/ConfigureNewComponentsTests.cs b/tests/EntKube.Clusters.Tests/Features/ConfigureNewComponentsTests.cs deleted file mode 100644 index 9994d48..0000000 --- a/tests/EntKube.Clusters.Tests/Features/ConfigureNewComponentsTests.cs +++ /dev/null @@ -1,448 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests the configure handler with the new component installers. -/// Verifies that reconfiguration requests route correctly and pass -/// the right configuration values to each installer. -/// -public class ConfigureNewComponentsTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock cnpgInstaller; - private readonly Mock redisInstaller; - private readonly Mock certManagerInstaller; - private readonly Mock externalSecretsInstaller; - private readonly Mock rabbitmqInstaller; - private readonly Mock customDnsInstaller; - private readonly ConfigureComponentHandler handler; - - public ConfigureNewComponentsTests() - { - repository = new InMemoryClusterRepository(); - - cnpgInstaller = new Mock(); - cnpgInstaller.Setup(i => i.ComponentName).Returns("cnpg"); - - redisInstaller = new Mock(); - redisInstaller.Setup(i => i.ComponentName).Returns("redis"); - - certManagerInstaller = new Mock(); - certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager"); - - externalSecretsInstaller = new Mock(); - externalSecretsInstaller.Setup(i => i.ComponentName).Returns("external-secrets"); - - rabbitmqInstaller = new Mock(); - rabbitmqInstaller.Setup(i => i.ComponentName).Returns("rabbitmq"); - - customDnsInstaller = new Mock(); - customDnsInstaller.Setup(i => i.ComponentName).Returns("custom-dns"); - - List installers = new() - { - cnpgInstaller.Object, - redisInstaller.Object, - certManagerInstaller.Object, - externalSecretsInstaller.Object, - rabbitmqInstaller.Object, - customDnsInstaller.Object - }; - - handler = new ConfigureComponentHandler(repository, installers); - } - - [Fact] - public async Task HandleAsync_ConfigureCnpg_PassesMonitoringFlag() - { - // Arrange — Reconfigure CNPG to enable Prometheus monitoring. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - cnpgInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cnpg", "CNPG reconfigured", new List { "monitoring=true" })); - - ConfigureComponentRequest request = new("cnpg", Values: new Dictionary - { - ["monitoringEnabled"] = "true", - ["operatorReplicas"] = "2" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - cnpgInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => - c.Values["monitoringEnabled"] == "true" && - c.Values["operatorReplicas"] == "2"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_ConfigureRedis_PassesSentinelAndPersistence() - { - // Arrange — Reconfigure Redis: scale replicas and change persistence size. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - redisInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "redis", "Redis reconfigured", new List { "replicas=5" })); - - ConfigureComponentRequest request = new("redis", Values: new Dictionary - { - ["replicaCount"] = "5", - ["persistenceSize"] = "16Gi", - ["maxMemory"] = "512mb" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - redisInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => - c.Values["replicaCount"] == "5" && - c.Values["persistenceSize"] == "16Gi" && - c.Values["maxMemory"] == "512mb"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_ConfigureCertManager_PassesDnsSolverConfig() - { - // Arrange — Switch cert-manager to use Route53 DNS01 solver. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - certManagerInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cert-manager", "cert-manager reconfigured", - new List { "Updated issuers with Route53 DNS01" })); - - ConfigureComponentRequest request = new("cert-manager", Values: new Dictionary - { - ["letsEncryptEmail"] = "ops@company.com", - ["dnsSolverEnabled"] = "true", - ["dnsSolverProvider"] = "route53", - ["dnsSolverHostedZone"] = "Z1234567890" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - certManagerInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => - c.Values["dnsSolverProvider"] == "route53" && - c.Values["dnsSolverHostedZone"] == "Z1234567890"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_ConfigureExternalSecrets_PassesStoreProvider() - { - // Arrange — Add an AWS Secrets Manager ClusterSecretStore. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - externalSecretsInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "external-secrets", "ESO reconfigured", - new List { "ClusterSecretStore for AWS created" })); - - ConfigureComponentRequest request = new("external-secrets", Values: new Dictionary - { - ["storeProvider"] = "aws", - ["storeRegion"] = "eu-north-1", - ["storeSecretName"] = "aws-credentials" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - externalSecretsInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => - c.Values["storeProvider"] == "aws" && - c.Values["storeRegion"] == "eu-north-1"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_ConfigureRabbitMQ_PassesTopologyConfig() - { - // Arrange — Enable topology operator on RabbitMQ. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - rabbitmqInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "rabbitmq", "RabbitMQ reconfigured", - new List { "Topology operator enabled" })); - - ConfigureComponentRequest request = new("rabbitmq", Values: new Dictionary - { - ["topologyOperator"] = "true", - ["operatorReplicas"] = "2" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - rabbitmqInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => - c.Values["topologyOperator"] == "true" && - c.Values["operatorReplicas"] == "2"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_ConfigureCertManager_CaseInsensitiveName() - { - // Arrange — Component name matching should be case-insensitive. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - certManagerInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cert-manager", "Reconfigured", new List())); - - ConfigureComponentRequest request = new("Cert-Manager", Values: new Dictionary - { - ["replicas"] = "2" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - } - - [Fact] - public async Task HandleAsync_ConfigureWithNamespace_PassesNamespaceThrough() - { - // Arrange — Specify a custom namespace for Redis reconfiguration. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - redisInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "redis", "Reconfigured", new List())); - - ConfigureComponentRequest request = new("redis", Namespace: "custom-redis-ns", Values: new Dictionary - { - ["replicaCount"] = "5" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - redisInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => c.Namespace == "custom-redis-ns"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_ConfigureCustomDns_PassesStubZonesAndCorefile() - { - // Arrange — Reconfigure CoreDNS with new stub zones and custom Corefile entries. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - customDnsInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "custom-dns", "CoreDNS reconfigured", - new List { "Stub zones updated", "Custom Corefile entries applied" })); - - ConfigureComponentRequest request = new("custom-dns", Values: new Dictionary - { - ["stubZones"] = "corp.internal=10.0.0.53;svc.internal=10.0.1.53", - ["customCorefileEntries"] = "log\nrewrite name api.old.local api.new.local", - ["customRecords"] = "myservice.local=10.10.10.10" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - customDnsInstaller.Verify( - i => i.ConfigureAsync(cluster, - It.Is(c => - c.Values["stubZones"] == "corp.internal=10.0.0.53;svc.internal=10.0.1.53" && - c.Values["customCorefileEntries"] == "log\nrewrite name api.old.local api.new.local" && - c.Values["customRecords"] == "myservice.local=10.10.10.10"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_WithSchema_RejectsInvalidIntegerValue() - { - // Arrange — CNPG has a schema requiring operatorReplicas as Integer with min/max. - // Providing a non-numeric value should fail validation before reaching the installer. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - ConfigurationSchema cnpgSchema = ComponentSchemas.GetSchema("cnpg"); - cnpgInstaller.Setup(i => i.GetSchema()).Returns(cnpgSchema); - - ConfigureComponentRequest request = new("cnpg", Values: new Dictionary - { - ["operatorReplicas"] = "not-a-number" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — should fail validation without ever calling ConfigureAsync. - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("must be an integer"); - cnpgInstaller.Verify( - i => i.ConfigureAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task HandleAsync_WithSchema_RejectsValueOutOfRange() - { - // Arrange — Redis schema has maxReplicas=10 for replicas field. - // Providing a value above max should fail. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - ConfigurationSchema redisSchema = ComponentSchemas.GetSchema("redis"); - redisInstaller.Setup(i => i.GetSchema()).Returns(redisSchema); - - ConfigureComponentRequest request = new("redis", Values: new Dictionary - { - ["replicaCount"] = "99" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("must be at most 10"); - redisInstaller.Verify( - i => i.ConfigureAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [Fact] - public async Task HandleAsync_WithSchema_PassesValidConfiguration() - { - // Arrange — Provide valid values matching the CNPG schema. - // Validation should pass and ConfigureAsync should be called. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - ConfigurationSchema cnpgSchema = ComponentSchemas.GetSchema("cnpg"); - cnpgInstaller.Setup(i => i.GetSchema()).Returns(cnpgSchema); - cnpgInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cnpg", "Configured", new List())); - - ConfigureComponentRequest request = new("cnpg", Values: new Dictionary - { - ["operatorReplicas"] = "2", - ["monitoringEnabled"] = "true" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — validation passes, installer is called. - - result.IsSuccess.Should().BeTrue(); - cnpgInstaller.Verify( - i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny()), - Times.Once); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/DeleteClusterHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/DeleteClusterHandlerTests.cs deleted file mode 100644 index e515e33..0000000 --- a/tests/EntKube.Clusters.Tests/Features/DeleteClusterHandlerTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.DeleteCluster; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class DeleteClusterHandlerTests -{ - private readonly DeleteClusterHandler handler; - private readonly InMemoryClusterRepository repository; - - public DeleteClusterHandlerTests() - { - repository = new InMemoryClusterRepository(); - handler = new DeleteClusterHandler(repository); - } - - [Fact] - public async Task HandleAsync_WithExistingCluster_DeletesAndReturnsSuccess() - { - // Arrange — Register a cluster so we have something to delete. - - KubernetesCluster cluster = KubernetesCluster.Register( - "staging", - "https://staging-k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "staging-ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - // Act — Delete it by ID. - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert — The deletion succeeds and the cluster is no longer in the repository. - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? deleted = await repository.GetByIdAsync(cluster.Id); - deleted.Should().BeNull(); - } - - [Fact] - public async Task HandleAsync_WithNonExistentId_ReturnsFailure() - { - // Arrange — An ID that doesn't exist. - - Guid unknownId = Guid.NewGuid(); - - // Act - - Result result = await handler.HandleAsync(unknownId); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/DeployComponentHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/DeployComponentHandlerTests.cs deleted file mode 100644 index 0a8e17b..0000000 --- a/tests/EntKube.Clusters.Tests/Features/DeployComponentHandlerTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -public class DeployComponentHandlerTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock kyvernoInstaller; - private readonly Mock policiesInstaller; - private readonly DeployComponentHandler handler; - - public DeployComponentHandlerTests() - { - repository = new InMemoryClusterRepository(); - kyvernoInstaller = new Mock(); - policiesInstaller = new Mock(); - - kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno"); - policiesInstaller.Setup(i => i.ComponentName).Returns("security-policies"); - - List installers = new() { kyvernoInstaller.Object, policiesInstaller.Object }; - handler = new DeployComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - } - - [Fact] - public async Task HandleAsync_ClusterNotFound_ReturnsFailure() - { - // Arrange - - DeployComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(Guid.NewGuid(), request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_ClusterNotConnected_ReturnsFailure() - { - // Arrange — Cluster is Pending, can't deploy to it. - - KubernetesCluster cluster = KubernetesCluster.Register( - "pending-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - DeployComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - [Fact] - public async Task HandleAsync_UnknownComponent_ReturnsFailureWithAvailable() - { - // Arrange — Requesting a component that has no installer. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - DeployComponentRequest request = new("nonexistent-thing"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Should list available installers. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("kyverno"); - result.Error.Should().Contain("security-policies"); - } - - [Fact] - public async Task HandleAsync_ValidComponent_DelegatesToInstaller() - { - // Arrange — Deploy Kyverno on a connected cluster. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "kyverno", - Message: "Kyverno 3.3.4 installed successfully", - Actions: new List { "Helm install/upgrade kyverno v3.3.4" })); - - DeployComponentRequest request = new("kyverno", Version: "3.3.4"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Should succeed and return installer output. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Success.Should().BeTrue(); - result.Value.ComponentName.Should().Be("kyverno"); - result.Value.Actions.Should().Contain("Helm install/upgrade kyverno v3.3.4"); - } - - [Fact] - public async Task HandleAsync_InstallerFails_ReturnsFailure() - { - // Arrange — The installer encounters an error (e.g., helm timeout). - - KubernetesCluster cluster = KubernetesCluster.Register( - "flaky-cluster", "https://k8s.flaky:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: false, - ComponentName: "kyverno", - Message: "Helm failed (exit 1): timed out waiting for condition", - Actions: new List { "Error: timed out" })); - - DeployComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("timed out"); - } - - [Fact] - public async Task HandleAsync_PassesOptionsToInstaller() - { - // Arrange — Deploy security policies with custom parameters. - - KubernetesCluster cluster = KubernetesCluster.Register( - "config-cluster", "https://k8s.cfg:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - ComponentInstallOptions? capturedOptions = null; - policiesInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .Callback((_, opts, _) => capturedOptions = opts) - .ReturnsAsync(new InstallResult(true, "security-policies", "Done", new List())); - - DeployComponentRequest request = new( - "security-policies", - Parameters: new Dictionary { ["validationAction"] = "Enforce" }); - - // Act - - await handler.HandleAsync(cluster.Id, request); - - // Assert — The installer received the custom parameters. - - capturedOptions.Should().NotBeNull(); - capturedOptions!.Parameters.Should().ContainKey("validationAction"); - capturedOptions.Parameters!["validationAction"].Should().Be("Enforce"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/DeployNewComponentsTests.cs b/tests/EntKube.Clusters.Tests/Features/DeployNewComponentsTests.cs deleted file mode 100644 index 2eb2622..0000000 --- a/tests/EntKube.Clusters.Tests/Features/DeployNewComponentsTests.cs +++ /dev/null @@ -1,324 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests the deploy handler with all new component installers registered. -/// Verifies that the handler correctly routes to the right installer based on -/// component name and passes parameters through. -/// -public class DeployNewComponentsTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock cnpgInstaller; - private readonly Mock redisInstaller; - private readonly Mock certManagerInstaller; - private readonly Mock externalSecretsInstaller; - private readonly Mock rabbitmqInstaller; - private readonly Mock customDnsInstaller; - private readonly DeployComponentHandler handler; - - public DeployNewComponentsTests() - { - repository = new InMemoryClusterRepository(); - - cnpgInstaller = new Mock(); - cnpgInstaller.Setup(i => i.ComponentName).Returns("cnpg"); - - redisInstaller = new Mock(); - redisInstaller.Setup(i => i.ComponentName).Returns("redis"); - - certManagerInstaller = new Mock(); - certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager"); - - externalSecretsInstaller = new Mock(); - externalSecretsInstaller.Setup(i => i.ComponentName).Returns("external-secrets"); - - rabbitmqInstaller = new Mock(); - rabbitmqInstaller.Setup(i => i.ComponentName).Returns("rabbitmq"); - - customDnsInstaller = new Mock(); - customDnsInstaller.Setup(i => i.ComponentName).Returns("custom-dns"); - - List installers = new() - { - cnpgInstaller.Object, - redisInstaller.Object, - certManagerInstaller.Object, - externalSecretsInstaller.Object, - rabbitmqInstaller.Object, - customDnsInstaller.Object - }; - - handler = new DeployComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - } - - [Fact] - public async Task HandleAsync_DeployCnpg_CallsInstaller() - { - // Arrange — A connected cluster wants CloudNativePG installed. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - cnpgInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cnpg", "CloudNativePG 0.22.0 installed", new List { "Helm install" })); - - DeployComponentRequest request = new("cnpg", Version: "0.22.0"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.ComponentName.Should().Be("cnpg"); - cnpgInstaller.Verify( - i => i.InstallAsync(cluster, It.Is(o => o.Version == "0.22.0"), It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_DeployRedis_PassesSentinelParameter() - { - // Arrange — Deploy Redis with Sentinel HA enabled. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - redisInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "redis", "Redis installed", new List { "Helm install" })); - - DeployComponentRequest request = new("redis", Parameters: new Dictionary - { - ["sentinelEnabled"] = "true", - ["replicaCount"] = "3" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - redisInstaller.Verify( - i => i.InstallAsync(cluster, - It.Is(o => - o.Parameters!["sentinelEnabled"] == "true" && - o.Parameters["replicaCount"] == "3"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_DeployCertManager_PassesLetsEncryptConfig() - { - // Arrange — Deploy cert-manager with Let's Encrypt + Cloudflare DNS01. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - certManagerInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "cert-manager", "cert-manager installed with LE issuers", - new List { "Helm install", "ClusterIssuer created" })); - - DeployComponentRequest request = new("cert-manager", Parameters: new Dictionary - { - ["letsEncryptEmail"] = "admin@example.com", - ["httpSolverEnabled"] = "true", - ["dnsSolverEnabled"] = "true", - ["dnsSolverProvider"] = "cloudflare", - ["dnsSolverSecretName"] = "cloudflare-api-token" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - certManagerInstaller.Verify( - i => i.InstallAsync(cluster, - It.Is(o => - o.Parameters!["letsEncryptEmail"] == "admin@example.com" && - o.Parameters["dnsSolverProvider"] == "cloudflare"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_DeployExternalSecrets_PassesVaultConfig() - { - // Arrange — Deploy ESO with Vault as the secret store provider. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - externalSecretsInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "external-secrets", "ESO installed", - new List { "Helm install", "ClusterSecretStore created" })); - - DeployComponentRequest request = new("external-secrets", Parameters: new Dictionary - { - ["storeProvider"] = "vault", - ["storeEndpoint"] = "http://vault.vault:8200" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - externalSecretsInstaller.Verify( - i => i.InstallAsync(cluster, - It.Is(o => - o.Parameters!["storeProvider"] == "vault" && - o.Parameters["storeEndpoint"] == "http://vault.vault:8200"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_DeployRabbitMQ_PassesTopologyOperatorParam() - { - // Arrange — Deploy RabbitMQ with messaging topology operator. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - rabbitmqInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "rabbitmq", "RabbitMQ operator installed", - new List { "Helm install", "Topology operator enabled" })); - - DeployComponentRequest request = new("rabbitmq", Parameters: new Dictionary - { - ["topologyOperator"] = "true", - ["monitoringEnabled"] = "true" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - rabbitmqInstaller.Verify( - i => i.InstallAsync(cluster, - It.Is(o => - o.Parameters!["topologyOperator"] == "true"), - It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_DisconnectedCluster_ReturnsFailure() - { - // Arrange — A cluster that isn't connected can't have components deployed. - - KubernetesCluster cluster = KubernetesCluster.Register( - "offline-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - DeployComponentRequest request = new("cnpg"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - [Fact] - public async Task HandleAsync_InstallerFails_ReturnsFailure() - { - // Arrange — The installer reports a failure. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - redisInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(false, "redis", "Helm timeout", new List { "Error: timeout" })); - - DeployComponentRequest request = new("redis"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("Helm timeout"); - } - - [Fact] - public async Task HandleAsync_DeployCustomDns_PassesStubZonesAndRecords() - { - // Arrange — Deploy custom DNS with stub zones and static records. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - customDnsInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "custom-dns", "CoreDNS customized", - new List { "Stub zones configured", "Custom records applied" })); - - DeployComponentRequest request = new("custom-dns", Parameters: new Dictionary - { - ["stubZones"] = "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53", - ["customRecords"] = "api.local=192.168.1.100;cache.local=192.168.1.101" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.ComponentName.Should().Be("custom-dns"); - customDnsInstaller.Verify( - i => i.InstallAsync(cluster, - It.Is(o => - o.Parameters!["stubZones"] == "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53" && - o.Parameters["customRecords"] == "api.local=192.168.1.100;cache.local=192.168.1.101"), - It.IsAny()), - Times.Once); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/DiscoverPrometheusHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/DiscoverPrometheusHandlerTests.cs deleted file mode 100644 index febbb71..0000000 --- a/tests/EntKube.Clusters.Tests/Features/DiscoverPrometheusHandlerTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.DiscoverPrometheus; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class DiscoverPrometheusHandlerTests -{ - private readonly InMemoryClusterRepository repository; - - public DiscoverPrometheusHandlerTests() - { - repository = new InMemoryClusterRepository(); - } - - [Fact] - public async Task HandleAsync_WithDiscoveredEndpoints_StoresThemOnCluster() - { - // Arrange — A registered cluster and a scanner that finds two Prometheus instances. - - KubernetesCluster cluster = KubernetesCluster.Register( - "production", - "https://k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "prod-ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - List fakeEndpoints = new() - { - new PrometheusEndpoint("http://prometheus.monitoring:9090", "monitoring", "prometheus-server"), - new PrometheusEndpoint("http://prometheus.observability:9090", "observability", "prom-stack") - }; - - FakePrometheusScanner scanner = new(fakeEndpoints); - DiscoverPrometheusHandler handler = new(repository, scanner); - - // Act — Run discovery on the cluster. - - Result> result = await handler.HandleAsync(cluster.Id); - - // Assert — Both endpoints should be stored and returned. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(2); - result.Value![0].Namespace.Should().Be("monitoring"); - result.Value[1].Namespace.Should().Be("observability"); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.PrometheusEndpoints.Should().HaveCount(2); - } - - [Fact] - public async Task HandleAsync_WithNoPrometheus_ReturnsEmptyList() - { - // Arrange — A cluster with no Prometheus running. - - KubernetesCluster cluster = KubernetesCluster.Register( - "staging", - "https://staging.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "staging-ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - FakePrometheusScanner scanner = new(new List()); - DiscoverPrometheusHandler handler = new(repository, scanner); - - // Act - - Result> result = await handler.HandleAsync(cluster.Id); - - // Assert — Success but empty list. No Prometheus found is not an error. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeEmpty(); - } - - [Fact] - public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure() - { - // Arrange - - FakePrometheusScanner scanner = new(new List()); - DiscoverPrometheusHandler handler = new(repository, scanner); - - // Act - - Result> result = await handler.HandleAsync(Guid.NewGuid()); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_ReplacesExistingEndpoints_OnRediscovery() - { - // Arrange — A cluster that already had endpoints from a previous scan. - - KubernetesCluster cluster = KubernetesCluster.Register( - "production", - "https://k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "prod-ctx", Guid.NewGuid()); - - cluster.SetPrometheusEndpoints(new List - { - new("http://old-prometheus:9090", "legacy", "old-prom") - }); - - await repository.AddAsync(cluster); - - // A new scan finds a different Prometheus. - - List newEndpoints = new() - { - new PrometheusEndpoint("http://prometheus.monitoring:9090", "monitoring", "prometheus-server") - }; - - FakePrometheusScanner scanner = new(newEndpoints); - DiscoverPrometheusHandler handler = new(repository, scanner); - - // Act - - Result> result = await handler.HandleAsync(cluster.Id); - - // Assert — The old endpoint is replaced by the new one. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(1); - result.Value![0].ServiceName.Should().Be("prometheus-server"); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.PrometheusEndpoints.Should().HaveCount(1); - } -} - -/// -/// Test double that returns pre-configured Prometheus endpoints -/// without actually connecting to a Kubernetes cluster. -/// -public class FakePrometheusScanner : IPrometheusScanner -{ - private readonly List endpoints; - - public FakePrometheusScanner(List endpoints) - { - this.endpoints = endpoints; - } - - public Task> ScanAsync(KubernetesCluster cluster, CancellationToken ct = default) - { - return Task.FromResult(endpoints); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/EfClusterRepositoryTests.cs b/tests/EntKube.Clusters.Tests/Features/EfClusterRepositoryTests.cs deleted file mode 100644 index 68c83a8..0000000 --- a/tests/EntKube.Clusters.Tests/Features/EfClusterRepositoryTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Infrastructure; -using FluentAssertions; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Integration tests for the EF Core cluster repository. These tests use a real -/// SQLite database to verify that EF change tracking works correctly — something -/// the in-memory repository can't catch. The key scenario: adding a child entity -/// (StorageBucket) to an already-tracked aggregate and saving without a -/// DbUpdateConcurrencyException. -/// -public class EfClusterRepositoryTests : IDisposable -{ - private readonly SqliteConnection connection; - private readonly ClustersDbContext db; - private readonly EfClusterRepository repository; - - public EfClusterRepositoryTests() - { - // Use an in-memory SQLite database that persists for the test lifetime. - - connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - - DbContextOptions options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - db = new ClustersDbContext(options); - db.Database.EnsureCreated(); - repository = new EfClusterRepository(db); - } - - [Fact] - public async Task UpdateAsync_AddingStorageBucket_DoesNotThrowConcurrencyException() - { - // Arrange — Register a cluster and persist it to the database. - // This simulates the real flow: a cluster already exists, and - // the user creates a storage bucket that needs to be saved. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s:6443", "kubeconfig-data", - Guid.NewGuid(), "test-ctx", Guid.NewGuid()); - - cluster.MarkConnected(); - cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("user", "pass", "eu-north-1")); - - await repository.AddAsync(cluster); - - // Act — Load the cluster back, add a new storage bucket, then save. - // Before the fix, db.Clusters.Update() would mark the new bucket as - // Modified instead of Added, causing DbUpdateConcurrencyException. - - KubernetesCluster? loaded = await repository.GetByIdAsync(cluster.Id); - loaded.Should().NotBeNull(); - - StorageBucket bucket = StorageBucket.Create( - "my-encrypted-bucket", "AKID", "SECRET", "https://s3.example.com", "eu-north-1", true); - - loaded!.AddStorageBucket(bucket); - - Func act = async () => await repository.UpdateAsync(loaded); - - // Assert — Should save without throwing. - - await act.Should().NotThrowAsync(); - - // Verify the bucket was actually persisted. - - KubernetesCluster? reloaded = await repository.GetByIdAsync(cluster.Id); - reloaded!.StorageBuckets.Should().HaveCount(1); - reloaded.StorageBuckets[0].Name.Should().Be("my-encrypted-bucket"); - reloaded.StorageBuckets[0].Encrypted.Should().BeTrue(); - } - - [Fact] - public async Task UpdateAsync_ReplacingComponents_PersistsNewComponents() - { - // Arrange — Register a cluster, run a first adoption scan, and persist. - // This mirrors the real flow: adoption scan detects components, then - // a second scan replaces them with fresh results. - - KubernetesCluster cluster = KubernetesCluster.Register( - "scan-cluster", "https://k8s:6443", "kubeconfig-data", - Guid.NewGuid(), "scan-ctx", Guid.NewGuid()); - - cluster.MarkConnected(); - - // Simulate first adoption scan — detect kyverno as installed. - - List firstScan = new() - { - new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Pod running" }, new List(), - new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary())) - }; - - cluster.UpdateComponents(firstScan); - await repository.AddAsync(cluster); - - // Act — Load the cluster, run a second scan with different results, - // and save. This is the adoption flow: UpdateComponents replaces the - // backing field, then UpdateAsync persists the change. - - KubernetesCluster? loaded = await repository.GetByIdAsync(cluster.Id); - loaded.Should().NotBeNull(); - loaded!.Components.Should().HaveCount(1); - - List secondScan = new() - { - new ComponentCheckResult("kyverno", ComponentStatus.Installed, - new List { "Pod running" }, new List(), - new DiscoveredConfiguration("3.8.0", "kyverno", "kyverno", new Dictionary())), - new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled, - new List(), new List { "No pods found" }) - }; - - loaded.UpdateComponents(secondScan); - - Func act = async () => await repository.UpdateAsync(loaded); - - // Assert — Should save without error and persist both components. - - await act.Should().NotThrowAsync(); - - KubernetesCluster? reloaded = await repository.GetByIdAsync(cluster.Id); - reloaded!.Components.Should().HaveCount(2); - reloaded.Components.Should().Contain(c => c.ComponentName == "kyverno" && c.Version == "3.8.0"); - reloaded.Components.Should().Contain(c => c.ComponentName == "cert-manager"); - } - - public void Dispose() - { - db.Dispose(); - connection.Dispose(); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/GetClusterByIdHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/GetClusterByIdHandlerTests.cs deleted file mode 100644 index f1497cc..0000000 --- a/tests/EntKube.Clusters.Tests/Features/GetClusterByIdHandlerTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.GetClusterById; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class GetClusterByIdHandlerTests -{ - private readonly GetClusterByIdHandler handler; - private readonly InMemoryClusterRepository repository; - - public GetClusterByIdHandlerTests() - { - repository = new InMemoryClusterRepository(); - handler = new GetClusterByIdHandler(repository); - } - - [Fact] - public async Task HandleAsync_WithExistingCluster_ReturnsSuccess() - { - // Arrange — Register a cluster so the repository has something to find. - - KubernetesCluster cluster = KubernetesCluster.Register( - "production-eu", - "https://k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "prod-ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - // Act — Look it up by its ID. - - Result result = await handler.HandleAsync(cluster.Id); - - // Assert — The handler should return the full cluster aggregate. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.Name.Should().Be("production-eu"); - } - - [Fact] - public async Task HandleAsync_WithNonExistentId_ReturnsFailure() - { - // Arrange — An ID that doesn't exist in the repository. - - Guid unknownId = Guid.NewGuid(); - - // Act - - Result result = await handler.HandleAsync(unknownId); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs b/tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs deleted file mode 100644 index 31cd49e..0000000 --- a/tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs +++ /dev/null @@ -1,348 +0,0 @@ -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.Components.Gitea; -using EntKube.Clusters.Infrastructure.Cleura; -using FluentAssertions; -using k8s.Models; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -public class GiteaComponentTests -{ - [Fact] - public void GiteaInstaller_HasCorrectComponentName() - { - // The installer's ComponentName must match the check's ComponentName - // so the coordinator can pair them correctly. - - GiteaInstaller installer = new( - new Microsoft.Extensions.Logging.Abstractions.NullLogger(), - new CleuraS3Client(Mock.Of()), - Mock.Of()); - - installer.ComponentName.Should().Be("gitea"); - } - - [Fact] - public void GiteaCheck_HasCorrectComponentName() - { - // The check's ComponentName identifies which component is being evaluated - // during cluster adoption scans. - - GiteaCheck check = new(new Microsoft.Extensions.Logging.Abstractions.NullLogger()); - - check.ComponentName.Should().Be("gitea"); - } - - [Fact] - public void GiteaCheck_HasDisplayName() - { - // The display name is shown in the UI so users can identify the component. - - GiteaCheck check = new(new Microsoft.Extensions.Logging.Abstractions.NullLogger()); - - check.DisplayName.Should().NotBeNullOrWhiteSpace(); - check.DisplayName.Should().Contain("Gitea"); - } - - [Fact] - public void GiteaSchema_IsRegistered() - { - // The configuration schema must be registered so the UI can render - // typed configuration forms for the Gitea component. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.ComponentName.Should().Be("gitea"); - schema.Parameters.Should().NotBeEmpty(); - } - - [Fact] - public void GiteaSchema_ContainsReplicasParameter() - { - // The replicas parameter controls how many Gitea pods are deployed. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "replicas"); - } - - [Fact] - public void GiteaSchema_ContainsActionsParameter() - { - // Gitea Actions (CI/CD) is a key feature that can be toggled. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "actionsEnabled"); - } - - [Fact] - public void GiteaSchema_ContainsStorageParameter() - { - // Repository persistence size is configurable for capacity planning. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "persistenceSize"); - } - - [Fact] - public void GiteaSchema_ContainsSshParameter() - { - // SSH access can be enabled/disabled depending on security requirements. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "sshEnabled"); - } - - [Fact] - public void GiteaInstaller_GetSchema_ReturnsGiteaSchema() - { - // The installer's default GetSchema() implementation should delegate - // to ComponentSchemas and return the gitea schema. - - IComponentInstaller installer = new GiteaInstaller( - new Microsoft.Extensions.Logging.Abstractions.NullLogger(), - new CleuraS3Client(Mock.Of()), - Mock.Of()); - - ConfigurationSchema schema = installer.GetSchema(); - - schema.ComponentName.Should().Be("gitea"); - schema.Parameters.Should().NotBeEmpty(); - } - - [Fact] - public void BuildActRunnerStatefulSet_DefaultReplicas_ReturnsValidStatefulSet() - { - // Arrange & Act - V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 2, "10Gi"); - - // Assert - sts.Metadata.Name.Should().Be("act-runner"); - sts.Metadata.NamespaceProperty.Should().Be("gitea"); - sts.Spec.Replicas.Should().Be(2); - sts.Spec.ServiceName.Should().Be("act-runner"); - } - - [Fact] - public void BuildActRunnerStatefulSet_UsesCorrectImage() - { - // Arrange & Act - V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi"); - - // Assert - V1Container container = sts.Spec.Template.Spec.Containers[0]; - container.Image.Should().Be("gitea/act_runner:latest-dind-rootless"); - container.Name.Should().Be("runner"); - } - - [Fact] - public void BuildActRunnerStatefulSet_SetsEnvironmentVariables() - { - // Arrange & Act - V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi"); - - // Assert — runner needs DOCKER_HOST and GITEA_INSTANCE_URL at minimum - V1Container container = sts.Spec.Template.Spec.Containers[0]; - container.Env.Should().Contain(e => e.Name == "DOCKER_HOST"); - container.Env.Should().Contain(e => e.Name == "GITEA_INSTANCE_URL"); - container.Env.Should().Contain(e => e.Name == "GITEA_RUNNER_REGISTRATION_TOKEN"); - } - - [Fact] - public void BuildActRunnerStatefulSet_ConfiguresPvcWithRequestedSize() - { - // Arrange & Act - V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 3, "20Gi"); - - // Assert - V1PersistentVolumeClaim pvc = sts.Spec.VolumeClaimTemplates[0]; - pvc.Metadata.Name.Should().Be("runner-data"); - pvc.Spec.Resources.Requests["storage"].ToString().Should().Be("20Gi"); - } - - [Fact] - public void BuildActRunnerStatefulSet_WithHarborUrl_SetsRegistryMirrorEnv() - { - // Arrange & Act — provide a Harbor registry URL for the runners. - V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi", "harbor.example.com"); - - // Assert — the Docker daemon should be configured to mirror through Harbor. - V1Container container = sts.Spec.Template.Spec.Containers[0]; - container.Env.Should().Contain(e => - e.Name == "DOCKERD_ROOTLESS_FLAGS" && - e.Value == "--registry-mirror=https://harbor.example.com"); - } - - [Fact] - public void BuildActRunnerStatefulSet_WithoutHarborUrl_NoMirrorEnv() - { - // Arrange & Act — no Harbor URL, runners pull images directly. - V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi"); - - // Assert — no Docker daemon mirror flag should be present. - V1Container container = sts.Spec.Template.Spec.Containers[0]; - container.Env.Should().NotContain(e => e.Name == "DOCKERD_ROOTLESS_FLAGS"); - } - - [Fact] - public void GiteaSchema_ContainsCnpgClusterNameParameter() - { - // Gitea should reference the shared CNPG cluster for its database. - // The cnpgClusterName parameter tells the installer which CNPG cluster - // to create the gitea database on, instead of requiring manual dbHost. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => - p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster); - } - - [Fact] - public void GiteaSchema_ContainsCnpgNamespaceParameter() - { - // The namespace where the CNPG cluster lives — needed to resolve - // the service endpoint ({clusterName}-rw.{namespace}.svc). - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace"); - } - - [Fact] - public void GiteaSchema_IngressProviderIsSelect() - { - // Ingress provider should be a dropdown selector, not a free-text - // field, so users pick from the supported providers. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - ConfigurationParameter ingressProvider = schema.Parameters.First(p => p.Key == "ingressProvider"); - - ingressProvider.Type.Should().Be(ParameterType.Select); - ingressProvider.AllowedValues.Should().Contain("traefik"); - ingressProvider.AllowedValues.Should().Contain("istio"); - ingressProvider.AllowedValues.Should().Contain("gatewayapi"); - } - - [Fact] - public void GiteaSchema_RedisClusterIsRedisClusterType() - { - // Redis should be a cluster selector dropdown, not a manual host - // textbox. The UI populates it from provisioned Redis clusters. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => - p.Key == "redisClusterName" && p.Type == ParameterType.RedisCluster); - } - - [Fact] - public void GiteaSchema_DatabaseFieldsDoNotIncludeManualHostUserPassword() - { - // With the CNPG cluster selector, manual dbHost/dbUser/dbPassword - // fields are no longer needed — credentials are auto-generated - // and stored in the secrets vault. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().NotContain(p => p.Key == "dbHost"); - schema.Parameters.Should().NotContain(p => p.Key == "dbUser"); - schema.Parameters.Should().NotContain(p => p.Key == "dbPassword"); - } - - [Fact] - public void GiteaSchema_ContainsOidcParameters() - { - // OIDC integration allows Gitea to authenticate users via an external - // identity provider like Keycloak. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "oidcEnabled" && p.Type == ParameterType.Boolean); - schema.Parameters.Should().Contain(p => p.Key == "oidcDiscoveryUrl"); - schema.Parameters.Should().Contain(p => p.Key == "oidcClientId"); - schema.Parameters.Should().Contain(p => p.Key == "oidcClientSecret"); - } - - [Fact] - public void GiteaSchema_OidcFieldsDependOnOidcEnabled() - { - // OIDC-specific fields should only be visible when oidcEnabled is true. - // This keeps the UI clean for installations without OIDC. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - ConfigurationParameter discoveryUrl = schema.Parameters.First(p => p.Key == "oidcDiscoveryUrl"); - - discoveryUrl.DependsOnKey.Should().Be("oidcEnabled"); - discoveryUrl.DependsOnValues.Should().Contain("true"); - } - - [Fact] - public void GiteaSchema_ObjectStorageCascading() - { - // Object storage fields should cascade based on the objectStorageType - // selection — selecting "bucket" shows the bucket selector, "s3" shows - // manual S3 fields, "azure" shows Azure fields, "none" hides everything. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - ConfigurationParameter storageType = schema.Parameters.First(p => p.Key == "objectStorageType"); - storageType.Type.Should().Be(ParameterType.Select); - storageType.AllowedValues.Should().Contain("none"); - storageType.AllowedValues.Should().Contain("bucket"); - storageType.AllowedValues.Should().Contain("s3"); - storageType.AllowedValues.Should().Contain("azure"); - - ConfigurationParameter bucket = schema.Parameters.First(p => p.Key == "storageBucketId"); - bucket.DependsOnKey.Should().Be("objectStorageType"); - bucket.DependsOnValues.Should().Contain("bucket"); - - ConfigurationParameter s3Endpoint = schema.Parameters.First(p => p.Key == "s3Endpoint"); - s3Endpoint.DependsOnKey.Should().Be("objectStorageType"); - s3Endpoint.DependsOnValues.Should().Contain("s3"); - - ConfigurationParameter azureName = schema.Parameters.First(p => p.Key == "azureAccountName"); - azureName.DependsOnKey.Should().Be("objectStorageType"); - azureName.DependsOnValues.Should().Contain("azure"); - } - - [Fact] - public void GiteaSchema_ContainsVersionParameter() - { - // Users should be able to select which version of Gitea to install - // or upgrade to. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => p.Key == "version"); - } - - [Fact] - public void GiteaSchema_ContainsProjectsParameter() - { - // Projects (kanban boards) should be configurable per installation. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - schema.Parameters.Should().Contain(p => - p.Key == "projectsEnabled" && p.Type == ParameterType.Boolean); - } - - [Fact] - public void GiteaSchema_SshPortDependsOnSshEnabled() - { - // SSH port should only be visible when SSH is enabled. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea"); - - ConfigurationParameter sshPort = schema.Parameters.First(p => p.Key == "sshPort"); - - sshPort.DependsOnKey.Should().Be("sshEnabled"); - sshPort.DependsOnValues.Should().Contain("true"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/HarborTests.cs b/tests/EntKube.Clusters.Tests/Features/HarborTests.cs deleted file mode 100644 index 5bacb5a..0000000 --- a/tests/EntKube.Clusters.Tests/Features/HarborTests.cs +++ /dev/null @@ -1,728 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.Components; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using k8s.Models; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the Harbor container registry component. Harbor provides: -/// -/// 1. **Container registry** — hosting private container images -/// 2. **Helm chart repository** — hosting Helm charts via OCI -/// 3. **Pull-through cache** — caching upstream registries (Docker Hub, ghcr.io, quay.io, etc.) -/// -/// The pull-through cache is especially valuable: all services and tenant apps -/// pull images through Harbor, gaining caching, vulnerability scanning, and -/// network isolation from public registries. -/// -/// Dependency chain: ingress (for Harbor UI/API) → cert-manager (TLS) → Harbor -/// -public class HarborTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock harborInstaller; - - public HarborTests() - { - repository = new InMemoryClusterRepository(); - - harborInstaller = new Mock(); - harborInstaller.Setup(i => i.ComponentName).Returns("harbor"); - } - - // ─── Harbor Installation ────────────────────────────────────────────────── - - [Fact] - public async Task Harbor_Install_DeploysRegistryWithPullThroughCache() - { - // Arrange — We want a full Harbor deployment with pull-through proxy cache - // enabled for major upstream registries. This gives us a local mirror that - // speeds up pulls and survives upstream outages. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Harbor 2.12.0 installed with pull-through cache for docker.io, ghcr.io, quay.io", - Actions: new List - { - "Ensured namespace 'harbor'", - "Helm install harbor v2.12.0", - "Created proxy cache project 'dockerhub-cache' → docker.io", - "Created proxy cache project 'ghcr-cache' → ghcr.io", - "Created proxy cache project 'quay-cache' → quay.io", - "Harbor registry available at registry.internal.corp.com" - })); - - DeployComponentHandler handler = new(repository, new List - { - harborInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("harbor", Version: "2.12.0", - Parameters: new Dictionary - { - ["harborDomain"] = "registry.internal.corp.com", - ["adminPassword"] = "Harbor12345", - ["storageClass"] = "longhorn", - ["storageSize"] = "100Gi", - ["cacheRegistries"] = "docker.io,ghcr.io,quay.io", - ["enableChartMuseum"] = "true" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Harbor deployed with proxy cache projects for each registry. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("Helm install harbor")); - result.Value.Actions.Should().Contain(a => a.Contains("dockerhub-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("quay-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("registry.internal.corp.com")); - - harborInstaller.Verify(i => i.InstallAsync( - cluster, - It.Is(o => - o.Parameters!["harborDomain"] == "registry.internal.corp.com" && - o.Parameters["cacheRegistries"] == "docker.io,ghcr.io,quay.io"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task Harbor_Install_WithTLS_UsesClusterIssuerForCertificate() - { - // Arrange — Harbor should use a cert-manager ClusterIssuer to auto-provision - // TLS certificates. This integrates with the internal CA or domain CA - // we set up earlier. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Harbor 2.12.0 installed with TLS via ClusterIssuer 'platform-internal-ca'", - Actions: new List - { - "Ensured namespace 'harbor'", - "Helm install harbor v2.12.0", - "Configured TLS via ClusterIssuer 'platform-internal-ca'", - "Created Ingress for registry.internal.corp.com with TLS", - "Harbor registry available at registry.internal.corp.com" - })); - - DeployComponentHandler handler = new(repository, new List - { - harborInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("harbor", Version: "2.12.0", - Parameters: new Dictionary - { - ["harborDomain"] = "registry.internal.corp.com", - ["adminPassword"] = "Harbor12345", - ["storageClass"] = "longhorn", - ["storageSize"] = "50Gi", - ["tlsIssuer"] = "platform-internal-ca", - ["enableTLS"] = "true" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — TLS configured via cert-manager integration. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("TLS via ClusterIssuer")); - result.Value.Actions.Should().Contain(a => a.Contains("Ingress")); - } - - [Fact] - public async Task Harbor_Install_WithoutPrerequisites_ReportsFailure() - { - // Arrange — Harbor requires ingress to be accessible. If the cluster - // has no ingress controller, the install should fail gracefully. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: false, - ComponentName: "harbor", - Message: "Harbor requires an ingress controller but none was detected", - Actions: new List { "Prerequisite check failed: no ingress controller found" })); - - DeployComponentHandler handler = new(repository, new List - { - harborInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("harbor", - Parameters: new Dictionary - { - ["harborDomain"] = "registry.internal.corp.com", - ["adminPassword"] = "Harbor12345" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Prerequisite failure reported. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("ingress"); - } - - // ─── Harbor Configuration ───────────────────────────────────────────────── - - [Fact] - public async Task Harbor_Configure_AddProxyCacheRegistry() - { - // Arrange — Harbor is running. We want to add a new upstream registry - // as a proxy cache (e.g., registry.k8s.io for Kubernetes images). - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Proxy cache project added for registry.k8s.io", - Actions: new List - { - "Created proxy cache project 'k8s-registry-cache' → registry.k8s.io", - "Proxy cache now covers: docker.io, ghcr.io, quay.io, registry.k8s.io" - })); - - List installers = new() { harborInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "harbor", - Values: new Dictionary - { - ["addProxyCache"] = "registry.k8s.io", - ["proxyCacheProjectName"] = "k8s-registry-cache" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — New proxy cache project created. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("registry.k8s.io")); - result.Value.Actions.Should().Contain(a => a.Contains("k8s-registry-cache")); - } - - [Fact] - public async Task Harbor_Configure_SetAsDefaultPullThroughCache() - { - // Arrange — We want to configure the cluster so all pod image pulls - // go through Harbor as a mirror/cache. This typically involves configuring - // containerd mirror settings or using Kyverno to mutate image references. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Cluster configured to use Harbor as default pull-through cache", - Actions: new List - { - "Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references", - "Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*", - "Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*", - "Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*" - })); - - List installers = new() { harborInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "harbor", - Values: new Dictionary - { - ["setDefaultCache"] = "true", - ["harborDomain"] = "registry.internal.corp.com", - ["cacheStrategy"] = "kyverno-rewrite" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — Kyverno policy created to rewrite image refs through Harbor. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("ClusterPolicy")); - result.Value.Actions.Should().Contain(a => a.Contains("docker.io")); - result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io")); - } - - [Fact] - public async Task Harbor_Configure_CreatePrivateProject() - { - // Arrange — Create a private project in Harbor for tenant images. - // Each tenant gets their own project with isolation. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Private project 'tenant-alpha' created", - Actions: new List - { - "Created private project 'tenant-alpha'", - "Created robot account 'robot$tenant-alpha-pull' with pull access", - "Created image pull Secret 'harbor-pull-tenant-alpha' in namespace 'tenant-alpha'" - })); - - List installers = new() { harborInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "harbor", - Values: new Dictionary - { - ["createProject"] = "tenant-alpha", - ["projectVisibility"] = "private", - ["createPullSecret"] = "true", - ["targetNamespace"] = "tenant-alpha" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — Project, robot account, and pull secret created. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("private project 'tenant-alpha'")); - result.Value.Actions.Should().Contain(a => a.Contains("robot account")); - result.Value.Actions.Should().Contain(a => a.Contains("pull Secret")); - } - - [Fact] - public async Task Harbor_Configure_EnableVulnerabilityScanning() - { - // Arrange — Enable automatic vulnerability scanning on all pushed images. - // Harbor uses Trivy for scanning. We want to configure the scan policy. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Vulnerability scanning configured", - Actions: new List - { - "Enabled automatic scan on push for all projects", - "Set vulnerability severity threshold to 'High'", - "Configured scan schedule: daily at 02:00 UTC" - })); - - List installers = new() { harborInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "harbor", - Values: new Dictionary - { - ["enableScanOnPush"] = "true", - ["severityThreshold"] = "High", - ["scanSchedule"] = "0 2 * * *" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — Scanning policies configured. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("scan on push")); - result.Value.Actions.Should().Contain(a => a.Contains("severity threshold")); - } - - // ─── Wire All Platform Services ────────────────────────────────────────── - - [Fact] - public async Task Harbor_Configure_WireAllServices_CreatesComprehensiveCacheAndPolicy() - { - // Arrange — Harbor is running. We want to wire ALL platform services - // (MinIO, CloudNativePG, cert-manager, Kyverno, Istio, Grafana, Prometheus, - // Redis, RabbitMQ, External Secrets) to pull images through Harbor. - // This creates proxy cache projects for every upstream registry used by - // our services and a Kyverno policy to rewrite image references. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "All platform services wired to pull through Harbor", - Actions: new List - { - "Ensured proxy cache project 'dockerhub-cache' → docker.io (MinIO, Istio, Grafana, Redis, RabbitMQ)", - "Ensured proxy cache project 'ghcr-cache' → ghcr.io (CloudNativePG, Kyverno, External Secrets)", - "Ensured proxy cache project 'quay-cache' → quay.io (cert-manager, trust-manager, Prometheus)", - "Ensured proxy cache project 'k8s-cache' → registry.k8s.io (CoreDNS, metrics-server)", - "Ensured proxy cache project 'gcr-cache' → gcr.io (Istio release images)", - "Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references", - "Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*", - "Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*", - "Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*", - "Policy rewrites registry.k8s.io/* → registry.internal.corp.com/k8s-cache/*", - "Policy rewrites gcr.io/* → registry.internal.corp.com/gcr-cache/*", - "All 12 platform services now pull through Harbor" - })); - - List installers = new() { harborInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "harbor", - Values: new Dictionary - { - ["wireAllServices"] = "true", - ["harborDomain"] = "registry.internal.corp.com" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — All registries cached and policy covers all platforms. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("docker.io") && a.Contains("MinIO")); - result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io") && a.Contains("CloudNativePG")); - result.Value.Actions.Should().Contain(a => a.Contains("quay.io") && a.Contains("cert-manager")); - result.Value.Actions.Should().Contain(a => a.Contains("registry.k8s.io")); - result.Value.Actions.Should().Contain(a => a.Contains("gcr.io")); - result.Value.Actions.Should().Contain(a => a.Contains("ClusterPolicy")); - result.Value.Actions.Should().Contain(a => a.Contains("12 platform services")); - - harborInstaller.Verify(i => i.ConfigureAsync( - cluster, - It.Is(c => - c.Values["wireAllServices"] == "true" && - c.Values["harborDomain"] == "registry.internal.corp.com"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task Harbor_Install_WithAllServices_IncludesAllRegistryCaches() - { - // Arrange — When installing Harbor with wireAllServices=true, it should - // automatically create proxy caches for all 5 upstream registries - // needed by platform services, not just the default 3. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Harbor 2.12.0 installed with pull-through cache for all platform registries", - Actions: new List - { - "Ensured namespace 'harbor'", - "Helm install harbor v2.12.0", - "Created proxy cache project 'dockerhub-cache' → docker.io", - "Created proxy cache project 'ghcr-cache' → ghcr.io", - "Created proxy cache project 'quay-cache' → quay.io", - "Created proxy cache project 'k8s-cache' → registry.k8s.io", - "Created proxy cache project 'gcr-cache' → gcr.io", - "Harbor registry available at registry.internal.corp.com" - })); - - DeployComponentHandler handler = new(repository, new List - { - harborInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("harbor", Version: "2.12.0", - Parameters: new Dictionary - { - ["harborDomain"] = "registry.internal.corp.com", - ["adminPassword"] = "Harbor12345", - ["storageClass"] = "longhorn", - ["storageSize"] = "100Gi", - ["cacheRegistries"] = "docker.io,ghcr.io,quay.io,registry.k8s.io,gcr.io" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — All 5 registries have proxy cache projects. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("dockerhub-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("quay-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("k8s-cache")); - result.Value.Actions.Should().Contain(a => a.Contains("gcr-cache")); - } - - // ─── Harbor Adoption Check ──────────────────────────────────────────────── - - [Fact] - public async Task Harbor_Check_DetectsExistingHarborDeployment() - { - // Arrange — A cluster already has Harbor installed via Helm. - // The adoption check should discover it, report version, and enumerate - // existing proxy cache projects. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock harborCheck = new(); - harborCheck.Setup(c => c.ComponentName).Returns("harbor"); - harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)"); - - harborCheck - .Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult( - "harbor", - ComponentStatus.Installed, - Details: new List - { - "Harbor 2.12.0 detected (Helm release 'harbor' in namespace 'harbor')", - "Registry endpoint: registry.internal.corp.com", - "Proxy cache projects: dockerhub-cache, ghcr-cache, quay-cache", - "Trivy scanner enabled", - "ChartMuseum enabled" - }, - MissingItems: new List(), - Configuration: new DiscoveredConfiguration( - Version: "2.12.0", - Namespace: "harbor", - HelmReleaseName: "harbor", - Values: new Dictionary - { - ["registryEndpoint"] = "registry.internal.corp.com", - ["proxyCacheProjects"] = "dockerhub-cache,ghcr-cache,quay-cache", - ["trivyEnabled"] = "true", - ["chartMuseumEnabled"] = "true", - ["storageClass"] = "longhorn", - ["storageSize"] = "100Gi" - }))); - - // Act - - ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster); - - // Assert — Harbor detected with full configuration discovery. - - checkResult.Status.Should().Be(ComponentStatus.Installed); - checkResult.Configuration.Should().NotBeNull(); - checkResult.Configuration!.Version.Should().Be("2.12.0"); - checkResult.Configuration.HelmReleaseName.Should().Be("harbor"); - checkResult.Configuration.Values["proxyCacheProjects"].Should().Contain("dockerhub-cache"); - checkResult.Details.Should().Contain(d => d.Contains("Trivy")); - } - - [Fact] - public async Task Harbor_Check_DetectsPartialInstallation() - { - // Arrange — Harbor is installed but some pods are not healthy (e.g., - // the database pod is in CrashLoopBackOff). The check reports Degraded. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock harborCheck = new(); - harborCheck.Setup(c => c.ComponentName).Returns("harbor"); - harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)"); - - harborCheck - .Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult( - "harbor", - ComponentStatus.Degraded, - Details: new List - { - "Harbor Helm release found in namespace 'harbor'", - "Core component: Running", - "Registry component: Running", - "Database component: CrashLoopBackOff" - }, - MissingItems: new List - { - "harbor-database pod unhealthy (CrashLoopBackOff)" - })); - - // Act - - ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster); - - // Assert — Degraded status with details about what's wrong. - - checkResult.Status.Should().Be(ComponentStatus.Degraded); - checkResult.MissingItems.Should().Contain(m => m.Contains("database")); - checkResult.Details.Should().Contain(d => d.Contains("CrashLoopBackOff")); - } - - private static KubernetesCluster CreateConnectedCluster() - { - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", - "apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []", - Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - return cluster; - } - - // ─── CNPG Database Integration ──────────────────────────────────────────── - - [Fact] - public void HarborSchema_ContainsCnpgClusterNameParameter() - { - // Harbor should be able to reference the shared CNPG cluster for its - // database instead of using the embedded PostgreSQL sub-chart. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor"); - - schema.Parameters.Should().Contain(p => - p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster); - } - - [Fact] - public void HarborSchema_ContainsCnpgNamespaceParameter() - { - // The CNPG cluster namespace is needed to resolve the primary service endpoint. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor"); - - schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace"); - } - - // ─── Harbor Database Credentials ────────────────────────────────────── - - [Fact] - public async Task Harbor_Install_WithCnpg_StoresCredentialsInVault() - { - // Arrange — When Harbor is installed with a CNPG cluster, the installer - // should store the generated database credentials in the vault at - // infrastructure/{clusterId}/harbor/database/* so they're auditable - // and recoverable. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - // Track vault calls made by the installer. - List vaultPaths = new(); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "harbor", - Message: "Harbor installed with external CNPG database", - Actions: new List - { - "Created database 'harbor' on CNPG cluster 'shared-pg'", - "Stored database credentials in Kubernetes Secret and vault", - "Helm install harbor v1.16.0" - })); - - DeployComponentHandler handler = new(repository, new List - { - harborInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("harbor", - Parameters: new Dictionary - { - ["harborDomain"] = "registry.dev.example.com", - ["cnpgClusterName"] = "shared-pg", - ["cnpgNamespace"] = "cnpg-system" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — The install result should mention both the K8s Secret and vault. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("Stored database credentials")); - } - - [Fact] - public void CnpgCredentialsSecret_ContainsAllRequiredKeys() - { - // When Harbor uses an external CNPG database, a K8s Secret is created - // with the database connection details. This secret must contain all - // four keys that Harbor expects: host, database, username, password. - - V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - "harbor-cnpg-credentials", "harbor", - "shared-pg-rw.cnpg-system.svc.cluster.local", - "harbor", "harbor", "secret-password-123"); - - secret.StringData.Should().ContainKey("host"); - secret.StringData.Should().ContainKey("database"); - secret.StringData.Should().ContainKey("username"); - secret.StringData.Should().ContainKey("password"); - secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local"); - secret.StringData["database"].Should().Be("harbor"); - secret.StringData["username"].Should().Be("harbor"); - secret.StringData["password"].Should().Be("secret-password-123"); - } - - [Fact] - public void CnpgCredentialsSecret_HasManagementLabels() - { - // The credentials secret should be labeled for EntKube management - // so it can be discovered and cleaned up. - - V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret( - "harbor-cnpg-credentials", "harbor", - "host", "harbor", "harbor", "pass"); - - secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube"); - secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs b/tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs deleted file mode 100644 index 442336b..0000000 --- a/tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.Components.Ingress; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for IngressCheck.DetectProvider — the static method that determines -/// which ingress provider is installed on a cluster by examining its component -/// list. This drives the auto-detection behavior so users never have to -/// manually select "istio" or "traefik" when configuring ingress. -/// -public class IngressCheckTests -{ - [Fact] - public void DetectProvider_IstioInstalled_ReturnsIstio() - { - // When the cluster has an Istio component marked as Installed, - // the detector should pick Istio as the provider. - - KubernetesCluster cluster = CreateClusterWithComponent("istio", ComponentStatus.Installed); - - string? provider = IngressCheck.DetectProvider(cluster); - - provider.Should().Be("istio"); - } - - [Fact] - public void DetectProvider_TraefikInstalled_ReturnsTraefik() - { - // When the cluster has a Traefik component marked as Installed, - // the detector should pick Traefik as the provider. - - KubernetesCluster cluster = CreateClusterWithComponent("traefik", ComponentStatus.Installed); - - string? provider = IngressCheck.DetectProvider(cluster); - - provider.Should().Be("traefik"); - } - - [Fact] - public void DetectProvider_IstioDegraded_StillReturnsIstio() - { - // Even if Istio is degraded (partially working), we should still - // detect it as the provider — the gateway infra can still be deployed. - - KubernetesCluster cluster = CreateClusterWithComponent("istio", ComponentStatus.Degraded); - - string? provider = IngressCheck.DetectProvider(cluster); - - provider.Should().Be("istio"); - } - - [Fact] - public void DetectProvider_NeitherInstalled_ReturnsNull() - { - // When no ingress controller is installed, the detector returns null - // so the installer can report "install Istio or Traefik first." - - KubernetesCluster cluster = CreateClusterWithComponent("kyverno", ComponentStatus.Installed); - - string? provider = IngressCheck.DetectProvider(cluster); - - provider.Should().BeNull(); - } - - [Fact] - public void DetectProvider_BothInstalled_PrefersIstio() - { - // If both Istio and Traefik are installed (unusual but possible), - // Istio takes priority because it requires dedicated gateway infrastructure. - - KubernetesCluster cluster = CreateClusterWithComponents( - ("istio", ComponentStatus.Installed), - ("traefik", ComponentStatus.Installed)); - - string? provider = IngressCheck.DetectProvider(cluster); - - provider.Should().Be("istio"); - } - - // ─── Gateway resolution from ingress component ─────────────────────── - - [Fact] - public void IngressComponent_WithIstioGateways_ExposesGatewayInfo() - { - // When the ingress component is installed with Istio and has discovered - // internal and external gateways, the component's configuration should - // contain the gateway details that services can use to auto-resolve. - - KubernetesCluster cluster = CreateClusterWithIngressConfig( - provider: "istio", - hasInternalGateway: true, - hasExternalGateway: true); - - ClusterComponent? ingress = cluster.Components.FirstOrDefault( - c => c.ComponentName == "ingress"); - - ingress.Should().NotBeNull(); - ingress!.Configuration["provider"].Should().Be("istio"); - ingress.Configuration["hasInternalGateway"].Should().Be("True"); - ingress.Configuration["hasExternalGateway"].Should().Be("True"); - } - - [Fact] - public void IngressComponent_WithTraefik_NoGatewayRefs() - { - // Traefik manages its own ingress — no Gateway API gateway refs needed. - // The component config should show traefik as provider. - - KubernetesCluster cluster = CreateClusterWithIngressConfig( - provider: "traefik", - hasInternalGateway: false, - hasExternalGateway: false); - - ClusterComponent? ingress = cluster.Components.FirstOrDefault( - c => c.ComponentName == "ingress"); - - ingress.Should().NotBeNull(); - ingress!.Configuration["provider"].Should().Be("traefik"); - ingress.Configuration.ContainsKey("hasInternalGateway").Should().BeFalse(); - } - - // ─── Helpers ───────────────────────────────────────────────────────── - - private static KubernetesCluster CreateClusterWithComponent(string componentName, ComponentStatus status) - { - return CreateClusterWithComponents((componentName, status)); - } - - private static KubernetesCluster CreateClusterWithComponents(params (string Name, ComponentStatus Status)[] componentDefs) - { - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.example.com", "fake-kubeconfig", - Guid.NewGuid(), "test-context", Guid.NewGuid()); - - List results = componentDefs - .Select(c => new ComponentCheckResult( - c.Name, c.Status, - Details: new List(), - MissingItems: new List())) - .ToList(); - - cluster.UpdateComponents(results); - - return cluster; - } - - /// - /// Creates a cluster with an ingress component that has discovered - /// configuration values — provider, gateway presence flags. This mimics - /// what the real IngressCheck discovers during an adoption scan. - /// - private static KubernetesCluster CreateClusterWithIngressConfig( - string provider, bool hasInternalGateway, bool hasExternalGateway) - { - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.example.com", "fake-kubeconfig", - Guid.NewGuid(), "test-context", Guid.NewGuid()); - - Dictionary configValues = new() - { - ["provider"] = provider - }; - - if (hasInternalGateway) - { - configValues["hasInternalGateway"] = "True"; - } - - if (hasExternalGateway) - { - configValues["hasExternalGateway"] = "True"; - } - - List results = new() - { - new ComponentCheckResult( - "ingress", ComponentStatus.Installed, - Details: new List(), - MissingItems: new List(), - Configuration: new DiscoveredConfiguration( - Version: null, - Namespace: "internal-ingress", - HelmReleaseName: null, - Values: configValues)) - }; - - cluster.UpdateComponents(results); - - return cluster; - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs b/tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs deleted file mode 100644 index 1e5fc46..0000000 --- a/tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster.Components.Ingress; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the ingress installer's provider-aware load balancer annotation -/// logic and Istio gateway Helm values generation. Each cloud provider requires -/// different Service annotations to create an internal (non-public) LoadBalancer. -/// These tests verify the correct annotations are produced for each provider. -/// -public class IngressInstallerTests -{ - // ─── Internal LB Annotation Mapping ────────────────────────────────── - - [Fact] - public void GetInternalLbAnnotations_Cleura_ReturnsOpenStackAnnotation() - { - // Cleura runs on OpenStack, which uses the openstack-internal-load-balancer - // annotation to tell the cloud controller to provision a private LB. - - Dictionary annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Cleura); - - annotations.Should().ContainKey("service.beta.kubernetes.io/openstack-internal-load-balancer"); - annotations["service.beta.kubernetes.io/openstack-internal-load-balancer"].Should().Be("true"); - annotations.Should().HaveCount(1); - } - - [Fact] - public void GetInternalLbAnnotations_Aws_ReturnsAwsSchemeAnnotation() - { - // AWS uses the load-balancer-scheme annotation to create an internal NLB/ALB. - // The newer annotation supersedes the older aws-load-balancer-internal one. - - Dictionary annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Aws); - - annotations.Should().ContainKey("service.beta.kubernetes.io/aws-load-balancer-scheme"); - annotations["service.beta.kubernetes.io/aws-load-balancer-scheme"].Should().Be("internal"); - annotations.Should().HaveCount(1); - } - - [Fact] - public void GetInternalLbAnnotations_Azure_ReturnsAzureAnnotation() - { - // Azure uses its own annotation to mark a LoadBalancer as internal. - - Dictionary annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Azure); - - annotations.Should().ContainKey("service.beta.kubernetes.io/azure-load-balancer-internal"); - annotations["service.beta.kubernetes.io/azure-load-balancer-internal"].Should().Be("true"); - annotations.Should().HaveCount(1); - } - - [Fact] - public void GetInternalLbAnnotations_Gcp_ReturnsGcpAnnotation() - { - // GCP uses the networking.gke.io annotation for internal LBs. - - Dictionary annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Gcp); - - annotations.Should().ContainKey("networking.gke.io/load-balancer-type"); - annotations["networking.gke.io/load-balancer-type"].Should().Be("Internal"); - annotations.Should().HaveCount(1); - } - - [Fact] - public void GetInternalLbAnnotations_None_ReturnsEmptyAnnotations() - { - // When no cloud provider is set, we can't determine the right annotation. - // Return empty and let the user handle it manually if needed. - - Dictionary annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.None); - - annotations.Should().BeEmpty(); - } - - // ─── Istio Gateway Helm Values ─────────────────────────────────────── - - [Fact] - public void GetIstioInternalGatewayValues_WithCleura_IncludesOpenStackAnnotation() - { - // When deploying to a Cleura cluster, the internal gateway's LoadBalancer - // Service must carry the OpenStack internal annotation so the cloud controller - // provisions a private LB accessible only from the internal network. - - string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.Cleura); - - values.Should().Contain("openstack-internal-load-balancer"); - values.Should().NotContain("azure-load-balancer-internal"); - values.Should().NotContain("aws-load-balancer"); - } - - [Fact] - public void GetIstioInternalGatewayValues_WithAws_IncludesAwsAnnotation() - { - string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.Aws); - - values.Should().Contain("aws-load-balancer-scheme"); - values.Should().Contain("internal"); - values.Should().NotContain("azure-load-balancer-internal"); - } - - [Fact] - public void GetIstioInternalGatewayValues_WithNone_HasNoAnnotationsBlock() - { - // When no provider is set, the values should not include any internal - // LB annotations — the Service gets a plain public LoadBalancer. - - string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.None); - - values.Should().NotContain("annotations:"); - values.Should().Contain("type: LoadBalancer"); - } - - [Fact] - public void GetIstioExternalGatewayValues_NeverIncludesInternalAnnotations() - { - // The external gateway should never have internal LB annotations - // regardless of the cloud provider — it's meant to be public. - - string values = IngressInstaller.GetIstioExternalGatewayValues(); - - values.Should().NotContain("internal"); - values.Should().Contain("type: LoadBalancer"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/InstallPrometheusHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/InstallPrometheusHandlerTests.cs deleted file mode 100644 index 2b0d5d5..0000000 --- a/tests/EntKube.Clusters.Tests/Features/InstallPrometheusHandlerTests.cs +++ /dev/null @@ -1,174 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.InstallPrometheus; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class InstallPrometheusHandlerTests -{ - private readonly InMemoryClusterRepository repository; - - public InstallPrometheusHandlerTests() - { - repository = new InMemoryClusterRepository(); - } - - [Fact] - public async Task HandleAsync_WithValidCluster_InstallsAndReturnsSuccess() - { - // Arrange — A cluster with no Prometheus. The user wants to install - // kube-prometheus-stack via Helm with a production configuration. - - KubernetesCluster cluster = KubernetesCluster.Register( - "fresh-cluster", - "https://k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "prod-ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - FakeMonitoringInstaller installer = new(success: true); - InstallPrometheusHandler handler = new(repository, new List { installer }); - - // Act — Install Prometheus on the cluster. - - Result result = await handler.HandleAsync( - new InstallPrometheusRequest(cluster.Id, "monitoring")); - - // Assert — The install should succeed and the cluster should have - // the Prometheus endpoint stored. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Namespace.Should().Be("monitoring"); - result.Value.ReleaseName.Should().Be("kube-prometheus-stack"); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.PrometheusEndpoints.Should().HaveCount(1); - updated.PrometheusEndpoints[0].Namespace.Should().Be("monitoring"); - } - - [Fact] - public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure() - { - // Arrange - - FakeMonitoringInstaller installer = new(success: true); - InstallPrometheusHandler handler = new(repository, new List { installer }); - - // Act - - Result result = await handler.HandleAsync( - new InstallPrometheusRequest(Guid.NewGuid(), "monitoring")); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_WhenHelmFails_ReturnsFailure() - { - // Arrange — Helm install fails (e.g., no connectivity, insufficient permissions). - - KubernetesCluster cluster = KubernetesCluster.Register( - "broken-cluster", - "https://broken.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - FakeMonitoringInstaller installer = new(success: false, errorMessage: "Tiller not available"); - InstallPrometheusHandler handler = new(repository, new List { installer }); - - // Act - - Result result = await handler.HandleAsync( - new InstallPrometheusRequest(cluster.Id, "monitoring")); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("Tiller not available"); - } - - [Fact] - public async Task HandleAsync_DefaultsToMonitoringNamespace() - { - // Arrange — The request doesn't specify a namespace, so it should - // default to "monitoring". - - KubernetesCluster cluster = KubernetesCluster.Register( - "default-ns-cluster", - "https://k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - FakeMonitoringInstaller installer = new(success: true); - InstallPrometheusHandler handler = new(repository, new List { installer }); - - // Act - - Result result = await handler.HandleAsync( - new InstallPrometheusRequest(cluster.Id, null)); - - // Assert — Should install to "monitoring" namespace. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Namespace.Should().Be("monitoring"); - } -} - -/// -/// Test double for the monitoring component installer. Returns pre-configured results -/// without actually running helm commands. -/// -public class FakeMonitoringInstaller : IComponentInstaller -{ - private readonly bool success; - private readonly string? errorMessage; - - public string ComponentName => "monitoring"; - - public FakeMonitoringInstaller(bool success, string? errorMessage = null) - { - this.success = success; - this.errorMessage = errorMessage; - } - - public Task InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default) - { - if (success) - { - return Task.FromResult(new InstallResult( - true, - ComponentName, - "kube-prometheus-stack installed", - new List { "Helm install succeeded" })); - } - - return Task.FromResult(new InstallResult( - false, - ComponentName, - errorMessage ?? "Install failed", - new List { $"Error: {errorMessage}" })); - } - - public Task ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default) - { - return Task.FromResult(new InstallResult( - true, - ComponentName, - "Monitoring reconfigured", - new List { "Configuration applied" })); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs b/tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs deleted file mode 100644 index 121a481..0000000 --- a/tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs +++ /dev/null @@ -1,546 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the Keycloak identity provider component. Keycloak provides: -/// -/// 1. **Multi-realm identity management** — each tenant or environment gets its own realm -/// 2. **BankID identity provider** — Swedish BankID as a login option per realm -/// 3. **Custom theming** — per-realm logos, colors, and backgrounds -/// 4. **Federation** — SAML, OIDC, and social providers per realm -/// -/// Keycloak is deployed via the Bitnami-free official Keycloak Operator or Helm chart. -/// Realms, providers, and themes are configured post-install via the Keycloak Admin API. -/// -public class KeycloakTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock keycloakInstaller; - - public KeycloakTests() - { - repository = new InMemoryClusterRepository(); - - keycloakInstaller = new Mock(); - keycloakInstaller.Setup(i => i.ComponentName).Returns("keycloak"); - } - - // ─── Keycloak Installation ──────────────────────────────────────────────── - - [Fact] - public async Task Keycloak_Install_DeploysWithOperatorAndDefaultRealm() - { - // Arrange — Deploy Keycloak with its operator and a default master realm. - // The operator manages Keycloak instances via CRDs (Keycloak, KeycloakRealm). - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: "Keycloak 26.0 installed with operator", - Actions: new List - { - "Ensured namespace 'keycloak'", - "Helm install keycloak-operator v26.0.0", - "Created Keycloak CR 'platform-keycloak'", - "Keycloak available at auth.internal.corp.com", - "BankID provider plugin deployed" - })); - - DeployComponentHandler handler = new(repository, new List - { - keycloakInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("keycloak", Version: "26.0.0", - Parameters: new Dictionary - { - ["keycloakDomain"] = "auth.internal.corp.com", - ["adminPassword"] = "admin123", - ["storageClass"] = "longhorn", - ["databaseType"] = "postgres", - ["installBankIdPlugin"] = "true" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Keycloak operator and instance deployed. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("keycloak-operator")); - result.Value.Actions.Should().Contain(a => a.Contains("Keycloak CR")); - result.Value.Actions.Should().Contain(a => a.Contains("BankID")); - - keycloakInstaller.Verify(i => i.InstallAsync( - cluster, - It.Is(o => - o.Parameters!["keycloakDomain"] == "auth.internal.corp.com" && - o.Parameters["installBankIdPlugin"] == "true"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task Keycloak_Install_WithoutDatabase_ReportsFailure() - { - // Arrange — Keycloak requires a database (internal or CloudNativePG). - // If no database is available, the install should fail gracefully. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: false, - ComponentName: "keycloak", - Message: "Keycloak requires a PostgreSQL database (CloudNativePG or external)", - Actions: new List { "Prerequisite check failed: no database available" })); - - DeployComponentHandler handler = new(repository, new List - { - keycloakInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("keycloak", - Parameters: new Dictionary - { - ["keycloakDomain"] = "auth.internal.corp.com" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("database"); - } - - // ─── Realm Management ───────────────────────────────────────────────────── - - [Fact] - public async Task Keycloak_Configure_CreateRealm_WithCustomTheme() - { - // Arrange — Create a new realm with a custom name, logo, background - // image, and brand colors. The realm is fully isolated and can have - // its own identity providers, users, and theme. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: "Realm 'tenant-alpha' created with custom theme", - Actions: new List - { - "Created realm 'tenant-alpha'", - "Set realm display name to 'Alpha Corp'", - "Deployed custom theme 'alpha-theme'", - "Theme: logo uploaded, primary color #1A73E8, background set", - "Realm login page configured with custom branding" - })); - - List installers = new() { keycloakInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["createRealm"] = "tenant-alpha", - ["realmDisplayName"] = "Alpha Corp", - ["themeLogo"] = "https://cdn.alpha.com/logo.png", - ["themeBackground"] = "https://cdn.alpha.com/bg.jpg", - ["themePrimaryColor"] = "#1A73E8", - ["themeSecondaryColor"] = "#FFFFFF", - ["themeLoginTitle"] = "Welcome to Alpha Corp" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — Realm created with full theming. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("realm 'tenant-alpha'")); - result.Value.Actions.Should().Contain(a => a.Contains("Alpha Corp")); - result.Value.Actions.Should().Contain(a => a.Contains("theme")); - result.Value.Actions.Should().Contain(a => a.Contains("#1A73E8")); - } - - [Fact] - public async Task Keycloak_Configure_AddBankIdProvider_ToRealm() - { - // Arrange — Add a Swedish BankID identity provider to a specific realm. - // BankID requires a service certificate (P12) and API endpoint configuration. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: "BankID provider added to realm 'tenant-alpha'", - Actions: new List - { - "Configured BankID identity provider in realm 'tenant-alpha'", - "Stored BankID service certificate in Keycloak keystore", - "BankID endpoint: https://appapi2.bankid.com/rp/v6.0", - "BankID provider enabled as login option" - })); - - List installers = new() { keycloakInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["realmName"] = "tenant-alpha", - ["addProvider"] = "bankid", - ["bankIdEnvironment"] = "production", - ["bankIdCertificateP12"] = "MIIEvgIBADANBg...", // base64 P12 - ["bankIdCertificatePassword"] = "cert-password", - ["bankIdDisplayName"] = "Logga in med BankID" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — BankID configured as identity provider. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("BankID") && a.Contains("tenant-alpha")); - result.Value.Actions.Should().Contain(a => a.Contains("certificate")); - result.Value.Actions.Should().Contain(a => a.Contains("appapi2.bankid.com")); - } - - [Fact] - public async Task Keycloak_Configure_AddOIDCProvider_ToRealm() - { - // Arrange — Add a generic OIDC identity provider (e.g., Azure AD, Google) - // to a realm, independently from other realms. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: "OIDC provider 'azure-ad' added to realm 'tenant-beta'", - Actions: new List - { - "Configured OIDC identity provider 'azure-ad' in realm 'tenant-beta'", - "Discovery URL: https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", - "Client ID configured", - "Provider enabled as login option" - })); - - List installers = new() { keycloakInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["realmName"] = "tenant-beta", - ["addProvider"] = "oidc", - ["providerAlias"] = "azure-ad", - ["providerDisplayName"] = "Sign in with Microsoft", - ["oidcDiscoveryUrl"] = "https://login.microsoftonline.com/abc123/.well-known/openid-configuration", - ["oidcClientId"] = "client-id-here", - ["oidcClientSecret"] = "client-secret-here" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — OIDC provider configured independently in tenant-beta. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("azure-ad") && a.Contains("tenant-beta")); - result.Value.Actions.Should().Contain(a => a.Contains("Discovery URL")); - } - - [Fact] - public async Task Keycloak_Configure_AddSAMLProvider_ToRealm() - { - // Arrange — Add a SAML identity provider to a realm. Useful for - // enterprise customers with ADFS or other SAML-based IdPs. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: "SAML provider 'corp-adfs' added to realm 'tenant-gamma'", - Actions: new List - { - "Configured SAML identity provider 'corp-adfs' in realm 'tenant-gamma'", - "SAML entity ID: https://adfs.corp.com/adfs/services/trust", - "SSO URL configured", - "Provider enabled as login option" - })); - - List installers = new() { keycloakInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["realmName"] = "tenant-gamma", - ["addProvider"] = "saml", - ["providerAlias"] = "corp-adfs", - ["providerDisplayName"] = "Enterprise SSO", - ["samlEntityId"] = "https://adfs.corp.com/adfs/services/trust", - ["samlSsoUrl"] = "https://adfs.corp.com/adfs/ls/", - ["samlCertificate"] = "MIIDXTCCAkWgAwIBAgI..." - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — SAML provider configured. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("SAML") && a.Contains("tenant-gamma")); - result.Value.Actions.Should().Contain(a => a.Contains("entity ID")); - } - - [Fact] - public async Task Keycloak_Configure_UpdateRealmTheme() - { - // Arrange — Update the theme of an existing realm. This changes logo, - // colors, and background without recreating the realm. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: "Theme updated for realm 'tenant-alpha'", - Actions: new List - { - "Updated theme for realm 'tenant-alpha'", - "Logo updated to new URL", - "Primary color changed to #FF5722", - "Background image updated" - })); - - List installers = new() { keycloakInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["realmName"] = "tenant-alpha", - ["updateTheme"] = "true", - ["themeLogo"] = "https://cdn.alpha.com/new-logo.png", - ["themeBackground"] = "https://cdn.alpha.com/new-bg.jpg", - ["themePrimaryColor"] = "#FF5722" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — Theme updated without affecting realm config. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("Updated theme")); - result.Value.Actions.Should().Contain(a => a.Contains("#FF5722")); - } - - [Fact] - public async Task Keycloak_Configure_MultipleProviders_DifferentRealms() - { - // Arrange — Verify that providers are realm-scoped. Adding BankID to - // tenant-alpha does NOT affect tenant-beta which has its own OIDC provider. - // This test verifies the realm isolation. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - keycloakInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync((KubernetesCluster c, ComponentConfiguration cfg, CancellationToken _) => - { - string realm = cfg.Values["realmName"]; - string provider = cfg.Values["addProvider"]; - - return new InstallResult( - Success: true, - ComponentName: "keycloak", - Message: $"Provider '{provider}' added to realm '{realm}'", - Actions: new List - { - $"Configured {provider} provider in realm '{realm}' only", - $"Other realms not affected" - }); - }); - - List installers = new() { keycloakInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - // Act — Add BankID to tenant-alpha. - - ConfigureComponentRequest request1 = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["realmName"] = "tenant-alpha", - ["addProvider"] = "bankid", - ["bankIdEnvironment"] = "test" - }); - - Result result1 = await configHandler.HandleAsync(cluster.Id, request1); - - // Act — Add OIDC to tenant-beta. - - ConfigureComponentRequest request2 = new( - ComponentName: "keycloak", - Values: new Dictionary - { - ["realmName"] = "tenant-beta", - ["addProvider"] = "oidc", - ["providerAlias"] = "google", - ["oidcDiscoveryUrl"] = "https://accounts.google.com/.well-known/openid-configuration", - ["oidcClientId"] = "google-client-id", - ["oidcClientSecret"] = "google-secret" - }); - - Result result2 = await configHandler.HandleAsync(cluster.Id, request2); - - // Assert — Each realm got its own provider independently. - - result1.IsSuccess.Should().BeTrue(); - result1.Value!.Actions.Should().Contain(a => a.Contains("tenant-alpha") && a.Contains("only")); - - result2.IsSuccess.Should().BeTrue(); - result2.Value!.Actions.Should().Contain(a => a.Contains("tenant-beta") && a.Contains("only")); - } - - // ─── Keycloak Adoption Check ────────────────────────────────────────────── - - [Fact] - public async Task Keycloak_Check_DetectsExistingDeployment() - { - // Arrange — Keycloak is already running. The check discovers the - // instance, its realms, and configured providers. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock keycloakCheck = new(); - keycloakCheck.Setup(c => c.ComponentName).Returns("keycloak"); - keycloakCheck.Setup(c => c.DisplayName).Returns("Keycloak (Identity & Access Management)"); - - keycloakCheck - .Setup(c => c.CheckAsync(cluster, It.IsAny())) - .ReturnsAsync(new ComponentCheckResult( - "keycloak", - ComponentStatus.Installed, - Details: new List - { - "Keycloak 26.0 detected (operator in namespace 'keycloak')", - "Endpoint: auth.internal.corp.com", - "Realms: master, tenant-alpha, tenant-beta", - "tenant-alpha: BankID provider enabled, custom theme", - "tenant-beta: OIDC (Azure AD) provider enabled", - "BankID plugin version 1.2.0 installed" - }, - MissingItems: new List(), - Configuration: new DiscoveredConfiguration( - Version: "26.0.0", - Namespace: "keycloak", - HelmReleaseName: "keycloak-operator", - Values: new Dictionary - { - ["endpoint"] = "auth.internal.corp.com", - ["realms"] = "master,tenant-alpha,tenant-beta", - ["bankIdPluginVersion"] = "1.2.0", - ["databaseType"] = "postgres" - }))); - - // Act - - ComponentCheckResult checkResult = await keycloakCheck.Object.CheckAsync(cluster); - - // Assert — Full Keycloak state discovered. - - checkResult.Status.Should().Be(ComponentStatus.Installed); - checkResult.Configuration.Should().NotBeNull(); - checkResult.Configuration!.Version.Should().Be("26.0.0"); - checkResult.Configuration.Values["realms"].Should().Contain("tenant-alpha"); - checkResult.Details.Should().Contain(d => d.Contains("BankID")); - checkResult.Details.Should().Contain(d => d.Contains("Azure AD")); - } - - private static KubernetesCluster CreateConnectedCluster() - { - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", - "apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []", - Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - return cluster; - } - - // ─── CNPG Database Integration ──────────────────────────────────────────── - - [Fact] - public void KeycloakSchema_ContainsCnpgClusterNameParameter() - { - // Keycloak should reference the shared CNPG cluster for its database - // instead of assuming a dedicated keycloak-db cluster exists. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("keycloak"); - - schema.Parameters.Should().Contain(p => - p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster); - } - - [Fact] - public void KeycloakSchema_ContainsCnpgNamespaceParameter() - { - // The CNPG cluster namespace — needed to resolve the database endpoint. - - ConfigurationSchema schema = ComponentSchemas.GetSchema("keycloak"); - - schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/KyvernoClusterSettingsProviderTests.cs b/tests/EntKube.Clusters.Tests/Features/KyvernoClusterSettingsProviderTests.cs deleted file mode 100644 index c7ccbe4..0000000 --- a/tests/EntKube.Clusters.Tests/Features/KyvernoClusterSettingsProviderTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Text.Json; -using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies; -using EntKube.Clusters.Features.ClusterSettings; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests the JSON parsing logic that extracts allowed registries from a Kyverno -/// ClusterPolicy. This is the core logic that reads the restrict-image-registries -/// policy and returns the list of approved registries. -/// -public class KyvernoClusterSettingsProviderTests -{ - [Fact] - public void ExtractRegistries_WithValidPolicy_ReturnsRegistryList() - { - // Arrange — a policy JSON with three allowed registries. - - string json = """ - { - "spec": { - "validationFailureAction": "Audit", - "rules": [{ - "name": "validate-image-registry", - "validate": { - "foreach": [{ - "list": "request.object.spec.[initContainers, containers][]", - "deny": { - "conditions": { - "all": [ - { "key": "{{ element.image }}", "operator": "NotEquals", "value": "docker.io/*" }, - { "key": "{{ element.image }}", "operator": "NotEquals", "value": "ghcr.io/*" }, - { "key": "{{ element.image }}", "operator": "NotEquals", "value": "registry.k8s.io/*" } - ] - } - } - }] - } - }] - } - } - """; - - // Act - - List registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json); - - // Assert - - registries.Should().BeEquivalentTo(new[] { "docker.io", "ghcr.io", "registry.k8s.io" }); - } - - [Fact] - public void ExtractRegistries_WithEmptyJson_ReturnsEmptyList() - { - // Act - - List registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson("{}"); - - // Assert - - registries.Should().BeEmpty(); - } - - [Fact] - public void ExtractRegistries_WithMalformedJson_ReturnsEmptyList() - { - // Act - - List registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson("not json"); - - // Assert - - registries.Should().BeEmpty(); - } - - [Fact] - public void ExtractRegistries_WithNoPolicyRules_ReturnsEmptyList() - { - // Arrange - - string json = """{ "spec": { "rules": [] } }"""; - - // Act - - List registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json); - - // Assert - - registries.Should().BeEmpty(); - } - - // ─── Round-trip tests ────────────────────────────────────────────────── - - [Fact] - public void BuildRegistryPolicy_RoundTrip_PreservesRegistries() - { - // When the settings provider writes a policy and then reads it back, - // the extracted registries should match what was written. This simulates - // the full Kubernetes API round-trip: build → serialize → parse. - - // Arrange — a custom list of registries. - - List registries = new() { "docker.io", "ghcr.io", "myregistry.example.com" }; - - // Act — build the policy, serialize it to JSON (simulating K8s storage), - // then extract registries back out. - - object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test message", "Audit"); - string json = JsonSerializer.Serialize(policy); - List extracted = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json); - - // Assert — the registries should survive the round-trip intact. - - extracted.Should().BeEquivalentTo(registries); - } - - [Fact] - public void BuildRegistryPolicy_IncludesExcludeBlock() - { - // The policy must include the exclude block so system namespaces - // (kube-system, cert-manager, etc.) are not subject to registry - // restrictions. Without this, saving registries via the Settings tab - // would create a policy that differs from the installer's policy and - // could break system pods. - - // Arrange - - List registries = new() { "docker.io" }; - - // Act - - object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test", "Audit"); - string json = JsonSerializer.Serialize(policy); - using JsonDocument doc = JsonDocument.Parse(json); - - // Assert — the rule should have an "exclude" block with namespace exclusions. - - JsonElement rule = doc.RootElement - .GetProperty("spec") - .GetProperty("rules")[0]; - - rule.TryGetProperty("exclude", out JsonElement exclude).Should().BeTrue( - "the policy rule must include an exclude block to protect system namespaces"); - - string excludeJson = exclude.GetRawText(); - excludeJson.Should().Contain("kube-system"); - } - - [Fact] - public void BuildRegistryPolicy_IncludesManagedByLabels() - { - // The policy labels must match what the SecurityPoliciesInstaller creates - // so the installer can find and replace the policy during reconfiguration. - - // Arrange - - List registries = new() { "docker.io" }; - - // Act - - object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test", "Enforce"); - string json = JsonSerializer.Serialize(policy); - using JsonDocument doc = JsonDocument.Parse(json); - - // Assert — should have both managed-by and part-of labels. - - JsonElement labels = doc.RootElement - .GetProperty("metadata") - .GetProperty("labels"); - - labels.GetProperty("app.kubernetes.io/managed-by").GetString().Should().Be("entkube"); - labels.GetProperty("app.kubernetes.io/part-of").GetString().Should().Be("security-policies"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs b/tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs deleted file mode 100644 index 3708099..0000000 --- a/tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs +++ /dev/null @@ -1,748 +0,0 @@ -using System.Text.Json; -using EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the LetsEncrypt component check — specifically the DNS01 solver -/// configuration extraction logic. When a cluster has ACME ClusterIssuers using -/// DNS01 challenges, the check should extract the provider type, secret references, -/// hosted zones, and domain selectors so the UI can display them properly. -/// -public class LetsEncryptCheckTests -{ - // ─── DNS01 Solver Parsing ───────────────────────────────────────────── - - [Fact] - public void ParseDns01Details_WithCloudflareSolver_ExtractsProviderAndSecret() - { - // Arrange — A ClusterIssuer with a Cloudflare DNS01 solver. The solver - // specifies an API token stored in a Kubernetes Secret. - - string json = """ - { - "cloudflare": { - "apiTokenSecretRef": { - "name": "cloudflare-api-token", - "key": "api-token" - } - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act — Extract DNS01 details from the solver configuration. - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert — Should identify Cloudflare as the provider and capture the secret name. - - details.Provider.Should().Be("cloudflare"); - details.SecretName.Should().Be("cloudflare-api-token"); - details.HostedZone.Should().BeNull(); - details.Project.Should().BeNull(); - } - - [Fact] - public void ParseDns01Details_WithRoute53Solver_ExtractsProviderAndHostedZone() - { - // Arrange — A ClusterIssuer with an AWS Route53 DNS01 solver. Route53 - // solvers include a hosted zone ID and region. - - string json = """ - { - "route53": { - "region": "eu-north-1", - "hostedZoneID": "Z1234567890ABC" - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert - - details.Provider.Should().Be("route53"); - details.HostedZone.Should().Be("Z1234567890ABC"); - details.SecretName.Should().BeNull(); - details.Project.Should().BeNull(); - } - - [Fact] - public void ParseDns01Details_WithRoute53AndSecretAccessKey_ExtractsSecretName() - { - // Arrange — A Route53 DNS01 solver using explicit credentials stored in - // a Kubernetes Secret via secretAccessKeySecretRef. This is the most common - // setup for non-IRSA (IAM Roles for Service Accounts) Route53 configurations. - - string json = """ - { - "route53": { - "region": "eu-north-1", - "hostedZoneID": "Z1234567890ABC", - "accessKeyID": "AKIAIOSFODNN7EXAMPLE", - "secretAccessKeySecretRef": { - "name": "aws-route53-credentials", - "key": "secret-access-key" - } - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert — Should capture both the hosted zone and the credential secret. - - details.Provider.Should().Be("route53"); - details.HostedZone.Should().Be("Z1234567890ABC"); - details.SecretName.Should().Be("aws-route53-credentials"); - details.Project.Should().BeNull(); - } - - [Fact] - public void ParseDns01Details_WithAzureDnsServicePrincipal_ExtractsAllFields() - { - // Arrange — An Azure DNS solver using a service principal for authentication. - // This is the most common Azure DNS setup: the ClusterIssuer authenticates - // to Azure using a clientID + clientSecret (stored in a K8s Secret), - // scoped to a specific subscription, tenant, resource group, and hosted zone. - - string json = """ - { - "azureDNS": { - "clientID": "app-id-12345", - "clientSecretSecretRef": { - "name": "azuredns-sp-secret", - "key": "client-secret" - }, - "subscriptionID": "sub-aaaa-bbbb-cccc", - "tenantID": "tenant-xxxx-yyyy", - "resourceGroupName": "dns-rg", - "hostedZoneName": "example.com", - "environment": "AzurePublicCloud" - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert — All Azure DNS fields should be extracted. - - details.Provider.Should().Be("azuredns"); - details.HostedZone.Should().Be("example.com"); - details.SecretName.Should().Be("azuredns-sp-secret"); - details.ClientId.Should().Be("app-id-12345"); - details.SubscriptionId.Should().Be("sub-aaaa-bbbb-cccc"); - details.TenantId.Should().Be("tenant-xxxx-yyyy"); - details.ResourceGroup.Should().Be("dns-rg"); - details.Project.Should().BeNull(); - } - - [Fact] - public void ParseDns01Details_WithAzureDnsManagedIdentity_ExtractsHostedZone() - { - // Arrange — An Azure DNS solver using managed identity (no clientSecret). - // Only the hosted zone and resource group are present. - - string json = """ - { - "azureDNS": { - "hostedZoneName": "example.com", - "resourceGroupName": "dns-rg", - "subscriptionID": "sub-123", - "environment": "AzurePublicCloud" - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert — Should capture what's available, leave missing fields null. - - details.Provider.Should().Be("azuredns"); - details.HostedZone.Should().Be("example.com"); - details.ResourceGroup.Should().Be("dns-rg"); - details.SubscriptionId.Should().Be("sub-123"); - details.SecretName.Should().BeNull(); - details.ClientId.Should().BeNull(); - details.TenantId.Should().BeNull(); - } - - [Fact] - public void ParseDns01Details_WithCloudDnsSolver_ExtractsProviderAndProject() - { - // Arrange — A Google Cloud DNS solver with a project and service account. - - string json = """ - { - "cloudDNS": { - "project": "my-gcp-project", - "serviceAccountSecretRef": { - "name": "clouddns-service-account", - "key": "key.json" - } - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert - - details.Provider.Should().Be("clouddns"); - details.Project.Should().Be("my-gcp-project"); - details.SecretName.Should().Be("clouddns-service-account"); - details.HostedZone.Should().BeNull(); - } - - [Fact] - public void ParseDns01Details_WithEmptyDns01_ReturnsUnknownProvider() - { - // Arrange — A DNS01 solver with no recognized provider (unusual but possible - // with custom webhook solvers). - - string json = """ - { - "webhook": { - "solverName": "custom-solver" - } - } - """; - - JsonElement dns01Element = JsonDocument.Parse(json).RootElement; - - // Act - - Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element); - - // Assert — Should report unknown when no recognized provider is found. - - details.Provider.Should().BeNull(); - details.SecretName.Should().BeNull(); - } - - // ─── Domain Selector Parsing ────────────────────────────────────────── - - [Fact] - public void ParseDnsZones_WithDnsZonesSelector_ExtractsZones() - { - // Arrange — A solver with a selector that scopes it to specific DNS zones. - // This is common when using DNS01 for wildcard certificates on specific domains. - - string json = """ - { - "selector": { - "dnsZones": ["example.com", "internal.example.com"] - }, - "dns01": { - "cloudflare": { - "apiTokenSecretRef": { - "name": "cf-token", - "key": "api-token" - } - } - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - List zones = LetsEncryptCheck.ParseDnsZones(solverElement); - - // Assert - - zones.Should().BeEquivalentTo(new[] { "example.com", "internal.example.com" }); - } - - [Fact] - public void ParseDnsZones_WithNoSelector_ReturnsEmptyList() - { - // Arrange — A solver with no selector (applies to all domains). - - string json = """ - { - "dns01": { - "cloudflare": { - "apiTokenSecretRef": { - "name": "cf-token", - "key": "api-token" - } - } - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - List zones = LetsEncryptCheck.ParseDnsZones(solverElement); - - // Assert - - zones.Should().BeEmpty(); - } - - // ─── Per-Issuer Config Values ───────────────────────────────────────── - - [Fact] - public void BuildConfigValues_WithMultipleIssuers_IncludesPerIssuerDetails() - { - // Arrange — Two ACME issuers: one staging with HTTP01, one production - // with DNS01 using Cloudflare. The config values should include both - // global summary and per-issuer detail so the UI can display each issuer. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-staging", - Email: "admin@example.com", - AcmeServer: "https://acme-staging-v02.api.letsencrypt.org/directory", - SolverType: "HTTP01", - IsReady: true, - Dns01Details: null, - DnsZones: new List()), - - new DetectedIssuer( - Name: "letsencrypt-prod", - Email: "admin@example.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "DNS01", - IsReady: true, - Dns01Details: new Dns01Details( - Provider: "cloudflare", - SecretName: "cloudflare-api-token", - HostedZone: null, - Project: null), - DnsZones: new List { "example.com" }) - }; - - // Act — Build the configuration values dictionary from the detected issuers. - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert — Global summary values. - - configValues["issuers"].Should().Be("letsencrypt-staging,letsencrypt-prod"); - configValues["issuerCount"].Should().Be("2"); - configValues["email"].Should().Be("admin@example.com"); - configValues["readyCount"].Should().Be("2"); - configValues["httpSolverEnabled"].Should().Be("true"); - configValues["dnsSolverEnabled"].Should().Be("true"); - - // Assert — Per-issuer detail for the DNS01 issuer. - - configValues["issuer.letsencrypt-prod.solverType"].Should().Be("DNS01"); - configValues["issuer.letsencrypt-prod.acmeServer"].Should().Be("https://acme-v02.api.letsencrypt.org/directory"); - configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("cloudflare"); - configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("cloudflare-api-token"); - configValues["issuer.letsencrypt-prod.dnsZones"].Should().Be("example.com"); - - // Assert — Per-issuer detail for the HTTP01 issuer. - - configValues["issuer.letsencrypt-staging.solverType"].Should().Be("HTTP01"); - configValues["issuer.letsencrypt-staging.acmeServer"].Should().Be("https://acme-staging-v02.api.letsencrypt.org/directory"); - } - - [Fact] - public void BuildConfigValues_WithDns01Solver_PopulatesGlobalDnsSolverFields() - { - // Arrange — A single issuer with DNS01. The global config values should - // include the DNS solver details so the "Current Configuration" view - // shows them without needing to expand per-issuer details. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-production", - Email: "certs@company.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "DNS01", - IsReady: true, - Dns01Details: new Dns01Details( - Provider: "route53", - SecretName: null, - HostedZone: "Z1234567890ABC", - Project: null), - DnsZones: new List { "company.com", "internal.company.com" }) - }; - - // Act - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert — Global DNS solver values populated from the first DNS01 issuer found. - - configValues["httpSolverEnabled"].Should().Be("false"); - configValues["dnsSolverEnabled"].Should().Be("true"); - configValues["dnsSolverProvider"].Should().Be("route53"); - configValues["dnsSolverHostedZone"].Should().Be("Z1234567890ABC"); - configValues["dnsZones"].Should().Be("company.com,internal.company.com"); - configValues.Should().NotContainKey("dnsSolverProject"); - } - - [Fact] - public void BuildConfigValues_WithCloudDnsSolver_IncludesProject() - { - // Arrange — A GCP Cloud DNS solver should include the project. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-prod", - Email: "ops@company.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "DNS01", - IsReady: true, - Dns01Details: new Dns01Details( - Provider: "clouddns", - SecretName: "gcp-dns-sa", - HostedZone: null, - Project: "my-gcp-project"), - DnsZones: new List()) - }; - - // Act - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert - - configValues["dnsSolverProvider"].Should().Be("clouddns"); - configValues["dnsSolverProject"].Should().Be("my-gcp-project"); - configValues["dnsSolverSecretName"].Should().Be("gcp-dns-sa"); - configValues["dnsSolverSecretNameGcp"].Should().Be("gcp-dns-sa"); - } - - [Fact] - public void BuildConfigValues_WithHttp01Only_DoesNotIncludeDnsFields() - { - // Arrange — An HTTP01-only issuer should not produce DNS solver fields. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-prod", - Email: "admin@example.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "HTTP01", - IsReady: true, - Dns01Details: null, - DnsZones: new List()) - }; - - // Act - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert - - configValues["httpSolverEnabled"].Should().Be("true"); - configValues["dnsSolverEnabled"].Should().Be("false"); - configValues.Should().NotContainKey("dnsSolverProvider"); - configValues.Should().NotContainKey("dnsSolverSecretName"); - configValues.Should().NotContainKey("dnsSolverHostedZone"); - configValues.Should().NotContainKey("dnsSolverProject"); - configValues.Should().NotContainKey("dnsSolverClientId"); - configValues.Should().NotContainKey("dnsSolverSubscriptionId"); - configValues.Should().NotContainKey("dnsSolverTenantId"); - configValues.Should().NotContainKey("dnsSolverResourceGroup"); - configValues.Should().NotContainKey("dnsZones"); - } - - [Fact] - public void BuildConfigValues_WithAzureDnsServicePrincipal_IncludesAllAzureFields() - { - // Arrange — An Azure DNS solver with full service principal configuration. - // All Azure-specific fields should appear in both global and per-issuer config. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-prod", - Email: "certs@company.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "DNS01", - IsReady: true, - Dns01Details: new Dns01Details( - Provider: "azuredns", - SecretName: "azuredns-sp-secret", - HostedZone: "company.com", - Project: null, - ClientId: "app-id-12345", - SubscriptionId: "sub-aaaa-bbbb", - TenantId: "tenant-xxxx", - ResourceGroup: "dns-rg"), - DnsZones: new List()) - }; - - // Act - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert — Global DNS solver values include all Azure fields. - - configValues["dnsSolverProvider"].Should().Be("azuredns"); - configValues["dnsSolverSecretName"].Should().Be("azuredns-sp-secret"); - configValues["dnsSolverSecretNameAzure"].Should().Be("azuredns-sp-secret"); - configValues["dnsSolverHostedZone"].Should().Be("company.com"); - configValues["dnsSolverClientId"].Should().Be("app-id-12345"); - configValues["dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb"); - configValues["dnsSolverTenantId"].Should().Be("tenant-xxxx"); - configValues["dnsSolverResourceGroup"].Should().Be("dns-rg"); - - // Assert — Per-issuer values also include Azure fields. - - configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("azuredns"); - configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("azuredns-sp-secret"); - configValues["issuer.letsencrypt-prod.dnsSolverClientId"].Should().Be("app-id-12345"); - configValues["issuer.letsencrypt-prod.dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb"); - configValues["issuer.letsencrypt-prod.dnsSolverTenantId"].Should().Be("tenant-xxxx"); - configValues["issuer.letsencrypt-prod.dnsSolverResourceGroup"].Should().Be("dns-rg"); - } - - // ─── HTTP01 Solver Mode Detection ───────────────────────────────────── - - [Fact] - public void BuildConfigValues_WithGatewayApiHttp01_ReportsGatewayMode() - { - // Arrange — An issuer using HTTP01 via gatewayHTTPRoute (Gateway API) - // instead of traditional Ingress. The config should report the mode - // so the UI can show Gateway API-specific fields. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-prod", - Email: "admin@example.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "HTTP01", - IsReady: true, - Dns01Details: null, - DnsZones: new List(), - Http01Mode: "gatewayHTTPRoute", - Http01GatewayName: "internal", - Http01GatewayNamespace: "internal-ingress") - }; - - // Act - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert — Global config should reflect gateway mode. - - configValues["httpSolverEnabled"].Should().Be("true"); - configValues["httpSolverMode"].Should().Be("gatewayHTTPRoute"); - configValues["httpSolverGatewayName"].Should().Be("internal"); - configValues["httpSolverGatewayNamespace"].Should().Be("internal-ingress"); - configValues.Should().NotContainKey("httpSolverIngressClass"); - } - - [Fact] - public void BuildConfigValues_WithIngressHttp01_ReportsIngressMode() - { - // Arrange — A traditional ingress-based HTTP01 solver. The config - // should report "ingress" mode with the ingress class. - - List issuers = new() - { - new DetectedIssuer( - Name: "letsencrypt-prod", - Email: "admin@example.com", - AcmeServer: "https://acme-v02.api.letsencrypt.org/directory", - SolverType: "HTTP01", - IsReady: true, - Dns01Details: null, - DnsZones: new List(), - Http01Mode: "ingress", - Http01IngressClass: "traefik") - }; - - // Act - - Dictionary configValues = LetsEncryptCheck.BuildConfigValues(issuers); - - // Assert — Global config should reflect ingress mode. - - configValues["httpSolverEnabled"].Should().Be("true"); - configValues["httpSolverMode"].Should().Be("ingress"); - configValues["httpSolverIngressClass"].Should().Be("traefik"); - configValues.Should().NotContainKey("httpSolverGatewayName"); - } - - // ─── HTTP01 Solver Parsing ──────────────────────────────────────────── - - [Fact] - public void ParseHttp01Details_WithGatewayHTTPRoute_ExtractsParentRef() - { - // Arrange — An HTTP01 solver configured with gatewayHTTPRoute (for - // clusters using Gateway API instead of Ingress). The solver specifies - // which Gateway to attach the challenge HTTPRoute to. - - string json = """ - { - "http01": { - "gatewayHTTPRoute": { - "parentRefs": [ - { - "name": "internal", - "namespace": "internal-ingress", - "kind": "Gateway" - } - ] - } - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); - - // Assert — Should identify gateway mode and extract parent ref. - - details.Mode.Should().Be("gatewayHTTPRoute"); - details.GatewayName.Should().Be("internal"); - details.GatewayNamespace.Should().Be("internal-ingress"); - details.IngressClass.Should().BeNull(); - } - - [Fact] - public void ParseHttp01Details_WithIngress_ExtractsIngressClass() - { - // Arrange — A traditional ingress-based HTTP01 solver. - - string json = """ - { - "http01": { - "ingress": { - "ingressClassName": "traefik" - } - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); - - // Assert - - details.Mode.Should().Be("ingress"); - details.IngressClass.Should().Be("traefik"); - details.GatewayName.Should().BeNull(); - details.GatewayNamespace.Should().BeNull(); - } - - [Fact] - public void ParseHttp01Details_WithIngressClassField_ExtractsClass() - { - // Arrange — Some older issuers use "class" instead of "ingressClassName". - - string json = """ - { - "http01": { - "ingress": { - "class": "nginx" - } - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); - - // Assert - - details.Mode.Should().Be("ingress"); - details.IngressClass.Should().Be("nginx"); - } - - [Fact] - public void ParseHttp01Details_WithNoHttp01_ReturnsNull() - { - // Arrange — A solver that only has dns01. - - string json = """ - { - "dns01": { - "cloudflare": {} - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - Http01Details? details = LetsEncryptCheck.ParseHttp01Details(solverElement); - - // Assert - - details.Should().BeNull(); - } - - [Fact] - public void ParseHttp01Details_WithGatewayHTTPRouteNoNamespace_OmitsNamespace() - { - // Arrange — A gatewayHTTPRoute solver where the parentRef doesn't - // specify a namespace (uses the issuer's namespace by default). - - string json = """ - { - "http01": { - "gatewayHTTPRoute": { - "parentRefs": [ - { - "name": "default-gateway" - } - ] - } - } - } - """; - - JsonElement solverElement = JsonDocument.Parse(json).RootElement; - - // Act - - Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement); - - // Assert - - details.Mode.Should().Be("gatewayHTTPRoute"); - details.GatewayName.Should().Be("default-gateway"); - details.GatewayNamespace.Should().BeNull(); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/RegisterClusterHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/RegisterClusterHandlerTests.cs deleted file mode 100644 index 3195c51..0000000 --- a/tests/EntKube.Clusters.Tests/Features/RegisterClusterHandlerTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.RegisterCluster; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class RegisterClusterHandlerTests -{ - private readonly RegisterClusterHandler handler; - private readonly InMemoryClusterRepository repository; - - public RegisterClusterHandlerTests() - { - repository = new InMemoryClusterRepository(); - handler = new RegisterClusterHandler(repository); - } - - [Fact] - public async Task HandleAsync_WithValidRequest_ReturnsSuccessWithClusterId() - { - // Arrange — A valid registration request with all required fields - // including the tenant and kubeconfig. - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - RegisterClusterRequest request = new("production-eu", "https://k8s.example.com:6443", "apiVersion: v1\nkind: Config", tenantId, "prod-context", environmentId); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — The handler should succeed and the cluster should be persisted with tenant link. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - KubernetesCluster? persisted = await repository.GetByIdAsync(result.Value!); - persisted.Should().NotBeNull(); - persisted!.Name.Should().Be("production-eu"); - persisted.TenantId.Should().Be(tenantId); - persisted.KubeConfig.Should().Contain("apiVersion"); - persisted.ContextName.Should().Be("prod-context"); - } - - [Fact] - public async Task HandleAsync_WithEmptyName_ReturnsFailure() - { - // Arrange — Missing cluster name. - - RegisterClusterRequest request = new("", "https://k8s.example.com:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("name"); - } - - [Fact] - public async Task HandleAsync_WithEmptyApiUrl_ReturnsFailure() - { - // Arrange — Missing API server URL. - - RegisterClusterRequest request = new("my-cluster", "", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("API server URL"); - } - - [Fact] - public async Task HandleAsync_WithEmptyKubeConfig_ReturnsFailure() - { - // Arrange — Missing kubeconfig content. A cluster cannot be registered - // without credentials to access it. - - RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("KubeConfig"); - } - - [Fact] - public async Task HandleAsync_WithEmptyTenantId_ReturnsFailure() - { - // Arrange — No tenant specified. Every cluster must belong to a tenant. - - RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "kubeconfig-data", Guid.Empty, "ctx", Guid.NewGuid()); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("tenant"); - } - - [Fact] - public async Task HandleAsync_WithEmptyEnvironmentId_ReturnsFailure() - { - // Arrange — No environment specified. Every cluster must belong to an environment. - - RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.Empty); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("environment"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs deleted file mode 100644 index 6f1bb79..0000000 --- a/tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.SetProvider; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class SetProviderHandlerTests -{ - private readonly SetProviderHandler handler; - private readonly InMemoryClusterRepository repository; - - public SetProviderHandlerTests() - { - repository = new InMemoryClusterRepository(); - handler = new SetProviderHandler(repository); - } - - [Fact] - public async Task HandleAsync_SetCleura_StoresProviderOnCluster() - { - // Arrange — A cluster is already registered with no provider. - // The user now wants to link it to their Cleura account. - - KubernetesCluster cluster = KubernetesCluster.Register( - "cleura-cluster", "https://k8s.cleura.cloud", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - SetProviderRequest request = new(cluster.Id, "Cleura", "my-user", "my-password", "Sto2"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — The provider and credentials should be persisted on the cluster. - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.Provider.Should().Be(CloudProvider.Cleura); - updated.ProviderCredentials!.Username.Should().Be("my-user"); - updated.ProviderCredentials.Region.Should().Be("Sto2"); - } - - [Fact] - public async Task HandleAsync_SetNone_ClearsProvider() - { - // Arrange — A cluster currently linked to Cleura. The user wants to unlink it. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("u", "p", "Fra1")); - await repository.AddAsync(cluster); - - SetProviderRequest request = new(cluster.Id, "None", null, null, null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.Provider.Should().Be(CloudProvider.None); - updated.ProviderCredentials.Should().BeNull(); - } - - [Fact] - public async Task HandleAsync_ClusterNotFound_ReturnsFailure() - { - // Arrange — Non-existent cluster ID. - - SetProviderRequest request = new(Guid.NewGuid(), "Cleura", "u", "p", "Sto2"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_InvalidProvider_ReturnsFailure() - { - // Arrange — An unrecognized provider name. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - SetProviderRequest request = new(cluster.Id, "UnknownCloud", "u", "p", "eu-west-1"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("Unsupported provider"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/TraefikInstallerTests.cs b/tests/EntKube.Clusters.Tests/Features/TraefikInstallerTests.cs deleted file mode 100644 index 865c864..0000000 --- a/tests/EntKube.Clusters.Tests/Features/TraefikInstallerTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster.Components.Traefik; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the Traefik installer's Helm values generation. Verifies that -/// the correct cloud-provider annotations are applied when internal LB mode -/// is enabled, and that no annotations leak through when disabled. -/// -public class TraefikInstallerTests -{ - [Fact] - public void GetTraefikValues_InternalLbWithCleura_IncludesOpenStackAnnotation() - { - // When Traefik is configured as an internal LB on Cleura, it should use - // the OpenStack annotation instead of dumping all provider annotations. - - Dictionary parameters = new() - { - ["internalLoadBalancer"] = "true" - }; - - string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Cleura); - - values.Should().Contain("openstack-internal-load-balancer"); - values.Should().NotContain("azure-load-balancer-internal"); - } - - [Fact] - public void GetTraefikValues_InternalLbWithAzure_IncludesAzureAnnotation() - { - Dictionary parameters = new() - { - ["internalLoadBalancer"] = "true" - }; - - string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Azure); - - values.Should().Contain("azure-load-balancer-internal"); - values.Should().NotContain("openstack-internal-load-balancer"); - } - - [Fact] - public void GetTraefikValues_NoInternalLb_HasNoAnnotations() - { - // When internalLoadBalancer is not set, no annotations should be emitted - // regardless of the cloud provider. - - Dictionary parameters = new(); - - string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Cleura); - - values.Should().NotContain("annotations:"); - } - - [Fact] - public void GetTraefikValues_CustomReplicas_IncludesReplicaCount() - { - // When a custom replica count is specified, the generated values - // should reflect that in the deployment section. - - Dictionary parameters = new() - { - ["replicas"] = "5" - }; - - string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.None); - - values.Should().Contain("replicas: 5"); - } - - [Fact] - public void GetTraefikValues_DashboardEnabled_IncludesDashboardConfig() - { - // When the dashboard is enabled, the generated values should include - // dashboard configuration. - - Dictionary parameters = new() - { - ["dashboardEnabled"] = "true" - }; - - string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.None); - - values.Should().Contain("dashboard"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/TrustBundleAndInternalCATests.cs b/tests/EntKube.Clusters.Tests/Features/TrustBundleAndInternalCATests.cs deleted file mode 100644 index 728c024..0000000 --- a/tests/EntKube.Clusters.Tests/Features/TrustBundleAndInternalCATests.cs +++ /dev/null @@ -1,609 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; -using EntKube.Clusters.Features.AdoptCluster.DeployComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests for the trust bundle and internal CA components. These two are co-dependent: -/// -/// 1. **cert-manager** — prerequisite, already tested elsewhere -/// 2. **trust-manager** — distributes CA bundles to namespaces via Bundle CRDs -/// 3. **internal-ca** — provisions a self-signed CA ClusterIssuer via cert-manager, -/// then adds its root certificate to the trust bundle -/// -/// The dependency chain: cert-manager → internal-ca → trust-manager (bundle update) -/// Both internal-ca and trust-manager require cert-manager to be installed first. -/// -public class TrustBundleAndInternalCATests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock trustManagerInstaller; - private readonly Mock internalCaInstaller; - - public TrustBundleAndInternalCATests() - { - repository = new InMemoryClusterRepository(); - - trustManagerInstaller = new Mock(); - trustManagerInstaller.Setup(i => i.ComponentName).Returns("trust-manager"); - - internalCaInstaller = new Mock(); - internalCaInstaller.Setup(i => i.ComponentName).Returns("internal-ca"); - } - - // ─── Trust Manager Installer ────────────────────────────────────────────── - - [Fact] - public async Task TrustManager_Install_DeploysHelmAndCreatesBundle() - { - // Arrange — cert-manager is already installed. Now we deploy trust-manager - // to distribute CA bundles cluster-wide. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - trustManagerInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "trust-manager", - Message: "trust-manager 0.14.0 installed with platform-trust-bundle Bundle", - Actions: new List - { - "Ensured namespace 'cert-manager'", - "Helm install trust-manager v0.14.0", - "Created Bundle 'platform-trust-bundle'" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("trust-manager", Version: "0.14.0", - Parameters: new Dictionary - { - ["bundleName"] = "platform-trust-bundle", - ["targetNamespaces"] = "*" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — trust-manager installed and Bundle CR created. - - result.IsSuccess.Should().BeTrue(); - result.Value!.ComponentName.Should().Be("trust-manager"); - result.Value.Actions.Should().Contain(a => a.Contains("Bundle")); - - trustManagerInstaller.Verify(i => i.InstallAsync( - cluster, - It.Is(o => - o.Version == "0.14.0" && - o.Parameters!["bundleName"] == "platform-trust-bundle"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task TrustManager_Configure_AddsCertificatesToBundle() - { - // Arrange — trust-manager is installed. We want to add extra CA certificates - // to the platform trust bundle (e.g., corporate PKI, external services). - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - trustManagerInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "trust-manager", - Message: "Trust bundle updated with 2 additional certificates", - Actions: new List - { - "Added certificate 'corporate-root-ca' to bundle", - "Added certificate 'partner-api-ca' to bundle" - })); - - List installers = new() { trustManagerInstaller.Object, internalCaInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - // Act — The user adds managed certificates to the trust bundle. - - ConfigureComponentRequest configRequest = new( - ComponentName: "trust-manager", - Values: new Dictionary - { - ["additionalCertificates"] = "corporate-root-ca:LS0tLS1CRUdJTi...;partner-api-ca:LS0tLS1CRUdJTi...", - ["bundleName"] = "platform-trust-bundle" - }); - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("corporate-root-ca")); - } - - // ─── Internal CA ────────────────────────────────────────────────────────── - - [Fact] - public async Task InternalCA_Install_CreatesSelfSignedCAAndAddsToBundle() - { - // Arrange — cert-manager and trust-manager are installed. Now we create - // an internal CA that signs service certificates and whose root is - // distributed via the trust bundle. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - internalCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "internal-ca", - Message: "Internal CA provisioned and added to trust bundle", - Actions: new List - { - "Created self-signed ClusterIssuer 'selfsigned-bootstrap'", - "Created root CA Certificate 'platform-internal-ca'", - "Created CA ClusterIssuer 'internal-ca'", - "Added root CA to Bundle 'platform-trust-bundle'" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("internal-ca", - Parameters: new Dictionary - { - ["caName"] = "platform-internal-ca", - ["bundleName"] = "platform-trust-bundle", - ["caOrganization"] = "EntKube Platform", - ["caDurationDays"] = "3650" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — CA created and injected into trust bundle. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("ClusterIssuer 'internal-ca'")); - result.Value.Actions.Should().Contain(a => a.Contains("Bundle")); - - internalCaInstaller.Verify(i => i.InstallAsync( - cluster, - It.Is(o => - o.Parameters!["caName"] == "platform-internal-ca" && - o.Parameters["bundleName"] == "platform-trust-bundle"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task InternalCA_Adopt_DiscoversExistingCAAndRegistersInBundle() - { - // Arrange — A cluster already has a CA ClusterIssuer. The adoption check - // should discover it and register its root cert in the trust bundle. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - internalCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "internal-ca", - Message: "Existing internal CA adopted and added to trust bundle", - Actions: new List - { - "Discovered existing CA ClusterIssuer 'existing-ca'", - "Read root CA certificate from Secret 'existing-ca-secret'", - "Added root CA to Bundle 'platform-trust-bundle'" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("internal-ca", - Parameters: new Dictionary - { - ["adoptExisting"] = "true", - ["existingIssuerName"] = "existing-ca", - ["bundleName"] = "platform-trust-bundle" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Existing CA adopted and cert added to bundle. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("Discovered existing CA")); - result.Value.Actions.Should().Contain(a => a.Contains("Bundle")); - } - - [Fact] - public async Task InternalCA_Configure_RotatesCACertificate() - { - // Arrange — The internal CA already exists. We want to configure it - // (e.g., update the duration or rotate the CA cert). - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - internalCaInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "internal-ca", - Message: "Internal CA reconfigured", - Actions: new List - { - "Updated CA Certificate duration to 7300 days", - "Refreshed root CA in Bundle 'platform-trust-bundle'" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - // Act - - ComponentConfiguration config = new("cert-manager", new Dictionary - { - ["caDurationDays"] = "7300", - ["bundleName"] = "platform-trust-bundle" - }); - - // Use the configure path through the deploy handler - // (the handler routes based on whether the component is already installed) - - internalCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "internal-ca", - Message: "Internal CA reconfigured", - Actions: new List - { - "Updated CA Certificate duration to 7300 days", - "Refreshed root CA in Bundle 'platform-trust-bundle'" - })); - - DeployComponentRequest request = new("internal-ca", - Parameters: config.Values); - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - } - - // ─── Dependency Chain ───────────────────────────────────────────────────── - - [Fact] - public async Task InternalCA_WithoutCertManager_ReportsFailure() - { - // Arrange — cert-manager is NOT installed. internal-ca cannot function. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - internalCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: false, - ComponentName: "internal-ca", - Message: "cert-manager is required but not installed", - Actions: new List { "Prerequisite check failed: cert-manager not found" })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("internal-ca", - Parameters: new Dictionary - { - ["caName"] = "platform-internal-ca", - ["bundleName"] = "platform-trust-bundle" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — DeployComponentHandler wraps a failed InstallResult in Result.Failure. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("cert-manager"); - } - - [Fact] - public async Task TrustManager_AddManagedCertificate_UpdatesBundleWithNewCert() - { - // Arrange — trust-manager is installed. We add a new managed certificate - // (e.g., a partner's CA cert that our services need to trust). - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - trustManagerInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "trust-manager", - Message: "Certificate added to trust bundle", - Actions: new List - { - "Created ConfigMap 'managed-cert-partner-api' with certificate data", - "Updated Bundle 'platform-trust-bundle' to include new source" - })); - - // Use the configure handler path (component already installed) - - List installers = new() { trustManagerInstaller.Object, internalCaInstaller.Object }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "trust-manager", - Values: new Dictionary - { - ["addCertificate"] = "true", - ["certificateName"] = "partner-api", - ["certificateData"] = "LS0tLS1CRUdJTi...", - ["bundleName"] = "platform-trust-bundle" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert — Certificate stored and bundle updated to reference it. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("ConfigMap")); - result.Value.Actions.Should().Contain(a => a.Contains("Bundle")); - } - - // ─── Domain-Scoped CA ─────────────────────────────────────────────────── - - [Fact] - public async Task DomainCA_Install_CreatesSeparateCAForInternalDomains() - { - // Arrange — The platform already has an internal-ca for service mesh certs. - // Now the team wants a SEPARATE CA specifically for *.internal.corp.com - // so they can issue certs for internal services on that domain. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock domainCaInstaller = new(); - domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca"); - - domainCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "domain-ca", - Message: "Domain CA 'corp-internal-ca' provisioned for *.internal.corp.com", - Actions: new List - { - "Created root CA Certificate 'corp-internal-ca'", - "Created CA ClusterIssuer 'corp-internal-ca'", - "Created Certificate policy: only signs for [*.internal.corp.com, *.svc.corp.local]", - "Added root CA to Bundle 'platform-trust-bundle'" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("domain-ca", - Parameters: new Dictionary - { - ["caName"] = "corp-internal-ca", - ["domains"] = "*.internal.corp.com,*.svc.corp.local", - ["caOrganization"] = "Corp Internal", - ["caDurationDays"] = "1825", - ["bundleName"] = "platform-trust-bundle" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Separate domain CA created with scope restrictions. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("ClusterIssuer 'corp-internal-ca'")); - result.Value.Actions.Should().Contain(a => a.Contains("policy")); - result.Value.Actions.Should().Contain(a => a.Contains("Bundle")); - - domainCaInstaller.Verify(i => i.InstallAsync( - cluster, - It.Is(o => - o.Parameters!["caName"] == "corp-internal-ca" && - o.Parameters["domains"] == "*.internal.corp.com,*.svc.corp.local"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task DomainCA_ImportExternalCA_CreatesIssuerFromProvidedCertAndKey() - { - // Arrange — The team has a public SSL cert from DigiCert for *.example.com - // and wants to use it as a CA/issuer for subdomains, or simply wants - // cert-manager to serve this cert for ingress. We import it. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock domainCaInstaller = new(); - domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca"); - - domainCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "domain-ca", - Message: "External CA 'digicert-example-com' imported for *.example.com", - Actions: new List - { - "Created TLS Secret 'digicert-example-com-tls' with imported cert+key", - "Created CA ClusterIssuer 'digicert-example-com'", - "Added CA certificate to Bundle 'platform-trust-bundle'" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("domain-ca", - Parameters: new Dictionary - { - ["caName"] = "digicert-example-com", - ["importExternal"] = "true", - ["tlsCert"] = "LS0tLS1CRUdJTi...", // base64 PEM cert - ["tlsKey"] = "LS0tLS1CRUdJTi...", // base64 PEM key - ["domains"] = "*.example.com", - ["bundleName"] = "platform-trust-bundle" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — External cert imported, ClusterIssuer created, trust bundle updated. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("TLS Secret")); - result.Value.Actions.Should().Contain(a => a.Contains("ClusterIssuer")); - result.Value.Actions.Should().Contain(a => a.Contains("Bundle")); - } - - [Fact] - public async Task DomainCA_ImportExternalCertOnly_AddsToBundleWithoutIssuer() - { - // Arrange — The team has an external CA root cert (no private key) that - // services need to trust (e.g., partner API CA). We just need it in the - // trust bundle — no ClusterIssuer needed since we can't sign certs with it. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock domainCaInstaller = new(); - domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca"); - - domainCaInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "domain-ca", - Message: "External CA certificate 'partner-root-ca' added to trust bundle", - Actions: new List - { - "Created ConfigMap 'managed-cert-partner-root-ca' with CA certificate", - "Added certificate to Bundle 'platform-trust-bundle'", - "No ClusterIssuer created (no private key provided — trust-only)" - })); - - DeployComponentHandler handler = new(repository, new List - { - trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object - }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - DeployComponentRequest request = new("domain-ca", - Parameters: new Dictionary - { - ["caName"] = "partner-root-ca", - ["importExternal"] = "true", - ["tlsCert"] = "LS0tLS1CRUdJTi...", // cert only, no key - ["bundleName"] = "platform-trust-bundle" - }); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Cert added to trust bundle but no issuer (can't sign without key). - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("trust-only")); - result.Value.Actions.Should().NotContain(a => a.Contains("ClusterIssuer 'partner-root-ca'") && !a.Contains("No ClusterIssuer")); - } - - [Fact] - public async Task DomainCA_Configure_UpdatesDomainRestrictions() - { - // Arrange — An existing domain CA needs its allowed domains updated. - - KubernetesCluster cluster = CreateConnectedCluster(); - await repository.AddAsync(cluster); - - Mock domainCaInstaller = new(); - domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca"); - - domainCaInstaller - .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "domain-ca", - Message: "Domain CA 'corp-internal-ca' reconfigured", - Actions: new List - { - "Updated Certificate policy: [*.internal.corp.com, *.new-domain.corp.com]" - })); - - List installers = new() - { - trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object - }; - ConfigureComponentHandler configHandler = new(repository, installers); - - ConfigureComponentRequest configRequest = new( - ComponentName: "domain-ca", - Values: new Dictionary - { - ["caName"] = "corp-internal-ca", - ["domains"] = "*.internal.corp.com,*.new-domain.corp.com" - }); - - // Act - - Result result = await configHandler.HandleAsync(cluster.Id, configRequest); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value!.Actions.Should().Contain(a => a.Contains("new-domain")); - } - - private static KubernetesCluster CreateConnectedCluster() - { - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.example.com:6443", - "apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []", - Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - return cluster; - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/UninstallComponentHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/UninstallComponentHandlerTests.cs deleted file mode 100644 index 111fe68..0000000 --- a/tests/EntKube.Clusters.Tests/Features/UninstallComponentHandlerTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.UninstallComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -public class UninstallComponentHandlerTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock kyvernoInstaller; - private readonly Mock monitoringInstaller; - private readonly UninstallComponentHandler handler; - - public UninstallComponentHandlerTests() - { - repository = new InMemoryClusterRepository(); - kyvernoInstaller = new Mock(); - monitoringInstaller = new Mock(); - - kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno"); - monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring"); - - List installers = new() { kyvernoInstaller.Object, monitoringInstaller.Object }; - handler = new UninstallComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - } - - [Fact] - public async Task HandleAsync_ClusterNotFound_ReturnsFailure() - { - // Arrange — request an uninstall on a cluster that doesn't exist. - - UninstallComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(Guid.NewGuid(), request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_ClusterNotConnected_ReturnsFailure() - { - // Arrange — Cluster is Pending, can't uninstall from it. - - KubernetesCluster cluster = KubernetesCluster.Register( - "pending-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - UninstallComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - [Fact] - public async Task HandleAsync_UnknownComponent_ReturnsFailureWithAvailable() - { - // Arrange — Requesting an uninstall for a component that has no installer. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - UninstallComponentRequest request = new("nonexistent-thing"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Should list available installers. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("kyverno"); - result.Error.Should().Contain("monitoring"); - } - - [Fact] - public async Task HandleAsync_ValidComponent_DelegatesToInstallerUninstall() - { - // Arrange — Uninstall Kyverno from a connected cluster. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "kyverno", - Message: "Kyverno uninstalled successfully", - Actions: new List { "Helm uninstall kyverno" })); - - UninstallComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Should succeed and return the uninstall result. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Success.Should().BeTrue(); - result.Value.ComponentName.Should().Be("kyverno"); - result.Value.Actions.Should().Contain("Helm uninstall kyverno"); - } - - [Fact] - public async Task HandleAsync_InstallerFails_ReturnsFailure() - { - // Arrange — The uninstall encounters an error. - - KubernetesCluster cluster = KubernetesCluster.Register( - "flaky-cluster", "https://k8s.flaky:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: false, - ComponentName: "kyverno", - Message: "Helm uninstall failed: resources still in use", - Actions: new List { "Error: resources still in use" })); - - UninstallComponentRequest request = new("kyverno"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("resources still in use"); - } - - [Fact] - public async Task HandleAsync_PassesOptionsToInstaller() - { - // Arrange — Uninstall monitoring with a custom namespace. - - KubernetesCluster cluster = KubernetesCluster.Register( - "config-cluster", "https://k8s.cfg:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - ComponentInstallOptions? capturedOptions = null; - - monitoringInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny(), It.IsAny())) - .Callback((_, opts, _) => capturedOptions = opts) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "monitoring", - Message: "Monitoring uninstalled", - Actions: new List())); - - UninstallComponentRequest request = new("monitoring", Namespace: "custom-monitoring"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — The handler should forward the namespace to the installer. - - result.IsSuccess.Should().BeTrue(); - capturedOptions.Should().NotBeNull(); - capturedOptions!.Namespace.Should().Be("custom-monitoring"); - } - - [Fact] - public async Task HandleAsync_ComponentNameIsCaseInsensitive() - { - // Arrange — Use mixed case for the component name. - - KubernetesCluster cluster = KubernetesCluster.Register( - "case-cluster", "https://k8s.case:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult( - Success: true, - ComponentName: "kyverno", - Message: "Uninstalled", - Actions: new List())); - - UninstallComponentRequest request = new("Kyverno"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/UpdateClusterStatusHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/UpdateClusterStatusHandlerTests.cs deleted file mode 100644 index 5b07b24..0000000 --- a/tests/EntKube.Clusters.Tests/Features/UpdateClusterStatusHandlerTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.UpdateClusterStatus; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Clusters.Tests.Features; - -public class UpdateClusterStatusHandlerTests -{ - private readonly UpdateClusterStatusHandler handler; - private readonly InMemoryClusterRepository repository; - - public UpdateClusterStatusHandlerTests() - { - repository = new InMemoryClusterRepository(); - handler = new UpdateClusterStatusHandler(repository); - } - - [Fact] - public async Task HandleAsync_MarkConnected_UpdatesClusterStatus() - { - // Arrange — A pending cluster exists. - - KubernetesCluster cluster = KubernetesCluster.Register( - "production", - "https://k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "prod-ctx", Guid.NewGuid()); - - await repository.AddAsync(cluster); - - // Act — Mark it as connected (health check passed). - - Result result = await handler.HandleAsync( - new UpdateClusterStatusRequest(cluster.Id, ClusterStatus.Connected)); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.Status.Should().Be(ClusterStatus.Connected); - updated.LastHealthCheckAt.Should().NotBeNull(); - } - - [Fact] - public async Task HandleAsync_MarkUnreachable_UpdatesClusterStatus() - { - // Arrange — A connected cluster. - - KubernetesCluster cluster = KubernetesCluster.Register( - "staging", - "https://staging-k8s.example.com:6443", - "kubeconfig-data", - Guid.NewGuid(), - "staging-ctx", Guid.NewGuid()); - - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - // Act — Health check fails. - - Result result = await handler.HandleAsync( - new UpdateClusterStatusRequest(cluster.Id, ClusterStatus.Unreachable)); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - updated!.Status.Should().Be(ClusterStatus.Unreachable); - } - - [Fact] - public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure() - { - // Arrange - - Guid unknownId = Guid.NewGuid(); - - // Act - - Result result = await handler.HandleAsync( - new UpdateClusterStatusRequest(unknownId, ClusterStatus.Connected)); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/UpgradeComponentHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/UpgradeComponentHandlerTests.cs deleted file mode 100644 index e0f93c4..0000000 --- a/tests/EntKube.Clusters.Tests/Features/UpgradeComponentHandlerTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster; -using EntKube.Clusters.Features.AdoptCluster.UpgradeComponent; -using EntKube.Clusters.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Clusters.Tests.Features; - -/// -/// Tests the upgrade handler — the flow for upgrading an already-installed -/// component to a specific version. Unlike deploy, upgrade validates that -/// the component is installed and requires an explicit target version. -/// -public class UpgradeComponentHandlerTests -{ - private readonly InMemoryClusterRepository repository; - private readonly Mock harborInstaller; - private readonly UpgradeComponentHandler handler; - - public UpgradeComponentHandlerTests() - { - repository = new InMemoryClusterRepository(); - harborInstaller = new Mock(); - harborInstaller.Setup(i => i.ComponentName).Returns("harbor"); - - List installers = new() { harborInstaller.Object }; - handler = new UpgradeComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - } - - [Fact] - public async Task HandleAsync_ClusterNotFound_ReturnsFailure() - { - // Arrange — Upgrading a component on a cluster that doesn't exist. - - UpgradeComponentRequest request = new("harbor", "1.17.0"); - - // Act - - Result result = await handler.HandleAsync(Guid.NewGuid(), request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_ClusterNotConnected_ReturnsFailure() - { - // Arrange — Can't upgrade components on a disconnected cluster. - - KubernetesCluster cluster = KubernetesCluster.Register( - "offline-cluster", "https://k8s.dev:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - await repository.AddAsync(cluster); - - UpgradeComponentRequest request = new("harbor", "1.17.0"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("connected"); - } - - [Fact] - public async Task HandleAsync_UnknownComponent_ReturnsFailure() - { - // Arrange — Trying to upgrade a component that has no installer. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - UpgradeComponentRequest request = new("nonexistent", "1.0.0"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No installer found"); - } - - [Fact] - public async Task HandleAsync_ComponentNotInstalled_ReturnsFailure() - { - // Arrange — Harbor exists as a component but is NotInstalled. You - // can't upgrade something that isn't there — use Deploy first. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List components = new() - { - new ComponentCheckResult("harbor", ComponentStatus.NotInstalled, - new List(), new List { "Not found" }) - }; - - cluster.UpdateComponents(components); - await repository.AddAsync(cluster); - - UpgradeComponentRequest request = new("harbor", "1.17.0"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not installed"); - } - - [Fact] - public async Task HandleAsync_ComponentNotTracked_ReturnsFailure() - { - // Arrange — The cluster has no components tracked at all. The - // component hasn't been scanned, so we don't know its state. - - KubernetesCluster cluster = KubernetesCluster.Register( - "empty-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - await repository.AddAsync(cluster); - - UpgradeComponentRequest request = new("harbor", "1.17.0"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not installed"); - } - - [Fact] - public async Task HandleAsync_InstalledComponent_CallsInstallerAndUpdatesVersion() - { - // Arrange — Harbor is installed at 1.16.0. The operator wants to - // upgrade to 1.17.0. The handler should call the installer with - // the new version and update the tracked version on success. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List components = new() - { - new ComponentCheckResult("harbor", ComponentStatus.Installed, - new List { "Running" }, new List(), - new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary())) - }; - - cluster.UpdateComponents(components); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.Is(o => o.Version == "1.17.0"), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "harbor", "Harbor upgraded to 1.17.0", new List { "Helm upgrade harbor v1.17.0" })); - - UpgradeComponentRequest request = new("harbor", "1.17.0"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — The handler should succeed and the tracked version - // should be updated to reflect the upgrade. - - result.IsSuccess.Should().BeTrue(); - result.Value!.Success.Should().BeTrue(); - result.Value.Message.Should().Contain("1.17.0"); - - KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id); - ClusterComponent harbor = updated!.Components.First(c => c.ComponentName == "harbor"); - harbor.Version.Should().Be("1.17.0"); - } - - [Fact] - public async Task HandleAsync_InstallerFails_ReturnsFailureWithoutUpdatingVersion() - { - // Arrange — The upgrade attempt fails (e.g., Helm reports an error). - // The tracked version should NOT be updated. - - KubernetesCluster cluster = KubernetesCluster.Register( - "prod-cluster", "https://k8s.prod:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List components = new() - { - new ComponentCheckResult("harbor", ComponentStatus.Installed, - new List { "Running" }, new List(), - new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary())) - }; - - cluster.UpdateComponents(components); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(false, "harbor", "Helm upgrade failed: chart version not found", new List())); - - UpgradeComponentRequest request = new("harbor", "99.99.99"); - - // Act - - Result result = await handler.HandleAsync(cluster.Id, request); - - // Assert — Failure reported, version unchanged. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("failed"); - - KubernetesCluster? unchanged = await repository.GetByIdAsync(cluster.Id); - ClusterComponent harbor = unchanged!.Components.First(c => c.ComponentName == "harbor"); - harbor.Version.Should().Be("1.16.0"); - } - - [Fact] - public async Task HandleAsync_PassesExistingNamespaceToInstaller() - { - // Arrange — When upgrading, the handler should pass the component's - // existing namespace so Helm upgrades in the right place. - - KubernetesCluster cluster = KubernetesCluster.Register( - "test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid()); - cluster.MarkConnected(); - - List components = new() - { - new ComponentCheckResult("harbor", ComponentStatus.Installed, - new List(), new List(), - new DiscoveredConfiguration("1.16.0", "custom-harbor-ns", "harbor", new Dictionary())) - }; - - cluster.UpdateComponents(components); - await repository.AddAsync(cluster); - - harborInstaller - .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) - .ReturnsAsync(new InstallResult(true, "harbor", "Upgraded", new List())); - - UpgradeComponentRequest request = new("harbor", "1.17.0"); - - // Act - - await handler.HandleAsync(cluster.Id, request); - - // Assert — The installer should receive the existing namespace. - - harborInstaller.Verify(i => i.InstallAsync( - cluster, - It.Is(o => o.Namespace == "custom-harbor-ns" && o.Version == "1.17.0"), - It.IsAny()), Times.Once); - } -} diff --git a/tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs b/tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs deleted file mode 100644 index be80bb1..0000000 --- a/tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs +++ /dev/null @@ -1,739 +0,0 @@ -using EntKube.Clusters.Domain; -using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies; -using FluentAssertions; -using k8s; -using k8s.Models; -using k8s.Autorest; -using Moq; -using Microsoft.Extensions.Logging; - -namespace EntKube.Clusters.Tests.Features; - -public class WorkloadRemediatorTests -{ - private readonly Mock mockClient; - private readonly Mock appsMock; - private readonly Mock> mockLogger; - private readonly WorkloadRemediator sut; - - public WorkloadRemediatorTests() - { - mockClient = new Mock(); - appsMock = new Mock(); - mockClient.Setup(c => c.AppsV1).Returns(appsMock.Object); - mockLogger = new Mock>(); - sut = new WorkloadRemediator(mockLogger.Object); - - // Default: empty workloads for all types. - SetupMockDeployments(new V1DeploymentList { Items = new List() }); - SetupMockStatefulSets(new V1StatefulSetList { Items = new List() }); - SetupMockDaemonSets(new V1DaemonSetList { Items = new List() }); - } - - // --- Seccomp Remediation --- - - [Fact] - public async Task RemediateAsync_DeploymentWithoutSeccomp_PatchesSeccompProfile() - { - // Arrange — A deployment exists that has no seccomp profile set. - // The remediator should patch it to add RuntimeDefault seccomp. - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — since the mock returns no workloads, no patches needed. - // This verifies the method runs without errors on empty clusters. - - result.Should().NotBeNull(); - result.PatchedWorkloads.Should().NotBeNull(); - result.PolicyExceptions.Should().NotBeNull(); - } - - [Fact] - public async Task RemediateAsync_ExcludedNamespacesAreSkipped() - { - // Arrange — Deployments in excluded namespaces should not be touched. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("kube-system", "coredns", withSeccomp: false, withReadonlyRootfs: false) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — kube-system workload should be excluded, so nothing patched. - - result.PatchedWorkloads.Should().BeEmpty(); - result.PolicyExceptions.Should().BeEmpty(); - } - - [Fact] - public async Task RemediateAsync_DeploymentMissingSeccomp_AddsSeccompPatch() - { - // Arrange — A deployment in a tenant namespace is missing seccomp. - // The remediator should patch it to add seccomp RuntimeDefault. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "my-app", withSeccomp: false, withReadonlyRootfs: true) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — The deployment was patched for seccomp compliance. - - result.PatchedWorkloads.Should().Contain(w => - w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "seccomp"); - } - - [Fact] - public async Task RemediateAsync_DeploymentMissingReadonlyRootfs_AddsReadonlyPatch() - { - // Arrange — A deployment missing readOnlyRootFilesystem on its containers. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "my-app", withSeccomp: true, withReadonlyRootfs: false) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert - - result.PatchedWorkloads.Should().Contain(w => - w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "readonlyRootfs"); - } - - [Fact] - public async Task RemediateAsync_PatchFails_CreatesPolicyException() - { - // Arrange — A deployment that needs patching but the patch fails. - // In that case, a PolicyException should be created for that workload. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "stubborn-app", withSeccomp: false, withReadonlyRootfs: false) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchFailure(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — Since the patch failed, we get a policy exception instead. - - result.PolicyExceptions.Should().Contain(e => - e.WorkloadName == "stubborn-app" && e.Namespace == "app-namespace"); - } - - [Fact] - public async Task RemediateAsync_StatefulSetMissingSeccomp_PatchesIt() - { - // Arrange — StatefulSets should also be remediated. - - V1StatefulSetList statefulSets = new() - { - Items = new List - { - CreateStatefulSet("data-namespace", "postgres", withSeccomp: false, withReadonlyRootfs: true) - } - }; - - SetupEmptyDeployments(); - SetupMockStatefulSets(statefulSets); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert - - result.PatchedWorkloads.Should().Contain(w => - w.Name == "postgres" && w.Namespace == "data-namespace" && w.Reason == "seccomp"); - } - - [Fact] - public async Task RemediateAsync_DaemonSetMissingReadonly_PatchesIt() - { - // Arrange — DaemonSets should also be remediated. - - V1DaemonSetList daemonSets = new() - { - Items = new List - { - CreateDaemonSet("monitoring", "node-exporter", withSeccomp: true, withReadonlyRootfs: false) - } - }; - - SetupEmptyDeployments(); - SetupEmptyStatefulSets(); - SetupMockDaemonSets(daemonSets); - SetupPatchSuccess(); - - // Act — monitoring is NOT in excluded list for this test. - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert - - result.PatchedWorkloads.Should().Contain(w => - w.Name == "node-exporter" && w.Namespace == "monitoring" && w.Reason == "readonlyRootfs"); - } - - [Fact] - public async Task RemediateAsync_CompliantWorkload_NoChanges() - { - // Arrange — A workload that already has seccomp and readonlyRootfs. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "compliant-app", withSeccomp: true, withReadonlyRootfs: true) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — Compliant workload should not be touched. - - result.PatchedWorkloads.Should().BeEmpty(); - result.PolicyExceptions.Should().BeEmpty(); - } - - [Fact] - public async Task CreatePolicyExceptionAsync_CreatesValidKyvernoPolicyException() - { - // Arrange — We need to create a PolicyException for a specific workload. - - PolicyExceptionRequest request = new( - WorkloadName: "my-app", - Namespace: "app-namespace", - WorkloadKind: "Deployment", - PolicyNames: new List { "require-seccomp-runtime-default", "require-readonly-root-filesystem" }); - - // Act - - object exception = WorkloadRemediator.BuildPolicyException(request); - - // Assert — The resulting object should have the correct Kyverno structure. - - exception.Should().NotBeNull(); - - Dictionary exceptionDict = (Dictionary)exception; - exceptionDict["apiVersion"].Should().Be("kyverno.io/v2"); - exceptionDict["kind"].Should().Be("PolicyException"); - } - - [Fact] - public async Task RemediateAsync_WritableRootFsApp_SkipsReadonlyPatch() - { - // Arrange — A Keycloak deployment in a tenant namespace doesn't have - // readOnlyRootFilesystem set. Because Keycloak requires a writable root, - // the remediator should NOT attempt to patch it for readonly compliance. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("tenant-apps", "keycloak", withSeccomp: true, withReadonlyRootfs: false, - labels: new Dictionary { ["app.kubernetes.io/name"] = "keycloak" }) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — No readonlyRootfs patch should be applied, and no exception needed. - - result.PatchedWorkloads.Should().NotContain(w => w.Reason == "readonlyRootfs"); - result.PolicyExceptions.Should().BeEmpty(); - } - - [Fact] - public async Task RemediateAsync_MinioStatefulSet_SkipsReadonlyPatch() - { - // Arrange — A MinIO StatefulSet needs a writable root filesystem for data - // storage. It should be skipped for readOnly remediation but still get - // seccomp patched if missing. - - V1StatefulSet minioSts = new() - { - Metadata = new V1ObjectMeta { Name = "minio", NamespaceProperty = "tenant-storage" }, - Spec = new V1StatefulSetSpec - { - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta - { - Labels = new Dictionary { ["app.kubernetes.io/name"] = "minio" } - }, - Spec = new V1PodSpec - { - Containers = new List - { - new() { Name = "minio", Image = "minio/minio:latest", SecurityContext = new V1SecurityContext() } - } - } - } - } - }; - - SetupMockDeployments(new V1DeploymentList { Items = new List() }); - SetupMockStatefulSets(new V1StatefulSetList { Items = new List { minioSts } }); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RemediateAsync( - mockClient.Object, - new List { "kube-system" }, - CancellationToken.None); - - // Assert — Seccomp should be patched, but readonlyRootfs should NOT. - - result.PatchedWorkloads.Should().Contain(w => w.Reason == "seccomp"); - result.PatchedWorkloads.Should().NotContain(w => w.Reason == "readonlyRootfs"); - } - - // --- Helper Methods --- - - private V1Deployment CreateDeployment(string ns, string name, bool withSeccomp, bool withReadonlyRootfs, Dictionary? labels = null) - { - V1SecurityContext containerSecurity = new() - { - ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null - }; - - V1PodSecurityContext podSecurity = new(); - - if (withSeccomp) - { - podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" }; - } - - return new V1Deployment - { - Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns }, - Spec = new V1DeploymentSpec - { - Template = new V1PodTemplateSpec - { - Metadata = new V1ObjectMeta { Labels = labels }, - Spec = new V1PodSpec - { - SecurityContext = podSecurity, - Containers = new List - { - new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity } - } - } - } - } - }; - } - - private V1StatefulSet CreateStatefulSet(string ns, string name, bool withSeccomp, bool withReadonlyRootfs) - { - V1SecurityContext containerSecurity = new() - { - ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null - }; - - V1PodSecurityContext podSecurity = new(); - - if (withSeccomp) - { - podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" }; - } - - return new V1StatefulSet - { - Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns }, - Spec = new V1StatefulSetSpec - { - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - SecurityContext = podSecurity, - Containers = new List - { - new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity } - } - } - } - } - }; - } - - private V1DaemonSet CreateDaemonSet(string ns, string name, bool withSeccomp, bool withReadonlyRootfs) - { - V1SecurityContext containerSecurity = new() - { - ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null - }; - - V1PodSecurityContext podSecurity = new(); - - if (withSeccomp) - { - podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" }; - } - - return new V1DaemonSet - { - Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns }, - Spec = new V1DaemonSetSpec - { - Template = new V1PodTemplateSpec - { - Spec = new V1PodSpec - { - SecurityContext = podSecurity, - Containers = new List - { - new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity } - } - } - } - } - }; - } - - private void SetupMockDeployments(V1DeploymentList deployments) - { - HttpOperationResponse response = new() { Body = deployments }; - appsMock.Setup(a => a.ListDeploymentForAllNamespacesWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ReturnsAsync(response); - } - - private void SetupEmptyDeployments() - { - SetupMockDeployments(new V1DeploymentList { Items = new List() }); - } - - private void SetupMockStatefulSets(V1StatefulSetList statefulSets) - { - HttpOperationResponse response = new() { Body = statefulSets }; - appsMock.Setup(a => a.ListStatefulSetForAllNamespacesWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ReturnsAsync(response); - } - - private void SetupEmptyStatefulSets() - { - SetupMockStatefulSets(new V1StatefulSetList { Items = new List() }); - } - - private void SetupMockDaemonSets(V1DaemonSetList daemonSets) - { - HttpOperationResponse response = new() { Body = daemonSets }; - appsMock.Setup(a => a.ListDaemonSetForAllNamespacesWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ReturnsAsync(response); - } - - private void SetupEmptyDaemonSets() - { - SetupMockDaemonSets(new V1DaemonSetList { Items = new List() }); - } - - private void SetupPatchSuccess() - { - HttpOperationResponse deployResponse = new() { Body = new V1Deployment() }; - appsMock.Setup(a => a.PatchNamespacedDeploymentWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ReturnsAsync(deployResponse); - - HttpOperationResponse stsResponse = new() { Body = new V1StatefulSet() }; - appsMock.Setup(a => a.PatchNamespacedStatefulSetWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ReturnsAsync(stsResponse); - - HttpOperationResponse dsResponse = new() { Body = new V1DaemonSet() }; - appsMock.Setup(a => a.PatchNamespacedDaemonSetWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ReturnsAsync(dsResponse); - } - - private void SetupPatchFailure() - { - appsMock.Setup(a => a.PatchNamespacedDeploymentWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") }); - - appsMock.Setup(a => a.PatchNamespacedStatefulSetWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") }); - - appsMock.Setup(a => a.PatchNamespacedDaemonSetWithHttpMessagesAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny>?>(), It.IsAny())) - .ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") }); - } - - // --- Revert Patches --- - - [Fact] - public async Task RevertPatchesAsync_DeploymentWithSeccomp_RemovesSeccompProfile() - { - // Arrange — A deployment that has seccomp RuntimeDefault set. The revert - // should remove the seccomp profile so the workload no longer requires it. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "my-app", withSeccomp: true, withReadonlyRootfs: false) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RevertPatchesAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — The deployment should have been reverted for seccomp. - - result.PatchedWorkloads.Should().Contain(w => - w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "revert-seccomp"); - } - - [Fact] - public async Task RevertPatchesAsync_DeploymentWithReadonlyRootfs_RemovesReadonly() - { - // Arrange — A deployment that has readOnlyRootFilesystem=true on its containers. - // The revert should set it back to false. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "my-app", withSeccomp: false, withReadonlyRootfs: true) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RevertPatchesAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — The deployment should have been reverted for readonlyRootfs. - - result.PatchedWorkloads.Should().Contain(w => - w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "revert-readonlyRootfs"); - } - - [Fact] - public async Task RevertPatchesAsync_WritableRootFsApp_SkipsReadonlyButRevertsSeccomp() - { - // Arrange — A Keycloak deployment has both seccomp and readOnlyRootFilesystem. - // Following the Terraform exclusion pattern, Keycloak is in WritableRootFsApps - // so readOnly was never applied by our remediator — we must NOT revert it. - // But seccomp WAS applied to everything, so it should still be reverted. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("tenant-apps", "keycloak", withSeccomp: true, withReadonlyRootfs: true, - labels: new Dictionary { ["app.kubernetes.io/name"] = "keycloak" }) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - SetupPatchSuccess(); - - // Act - - RemediationResult result = await sut.RevertPatchesAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — Seccomp reverted (applied to all), readOnly NOT reverted (excluded app). - - result.PatchedWorkloads.Should().Contain(w => w.Reason == "revert-seccomp"); - result.PatchedWorkloads.Should().NotContain(w => w.Reason == "revert-readonlyRootfs"); - } - - [Fact] - public async Task RevertPatchesAsync_ExcludedNamespace_SkipsWorkloads() - { - // Arrange — Workloads in excluded namespaces should not be reverted. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("kube-system", "coredns", withSeccomp: true, withReadonlyRootfs: true) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - - // Act - - RemediationResult result = await sut.RevertPatchesAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — kube-system workloads are excluded, so nothing reverted. - - result.PatchedWorkloads.Should().BeEmpty(); - } - - [Fact] - public async Task RevertPatchesAsync_CompliantAndUnpatched_NoChanges() - { - // Arrange — A workload that has neither seccomp nor readOnlyRootFilesystem - // set. Nothing to revert. - - V1DeploymentList deployments = new() - { - Items = new List - { - CreateDeployment("app-namespace", "vanilla-app", withSeccomp: false, withReadonlyRootfs: false) - } - }; - - SetupMockDeployments(deployments); - SetupEmptyStatefulSets(); - SetupEmptyDaemonSets(); - - // Act - - RemediationResult result = await sut.RevertPatchesAsync( - mockClient.Object, - new List { "kube-system", "kyverno" }, - CancellationToken.None); - - // Assert — Nothing to revert. - - result.PatchedWorkloads.Should().BeEmpty(); - } -} diff --git a/tests/EntKube.Identity.Tests/Domain/CustomerTests.cs b/tests/EntKube.Identity.Tests/Domain/CustomerTests.cs deleted file mode 100644 index 3ba20d2..0000000 --- a/tests/EntKube.Identity.Tests/Domain/CustomerTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using EntKube.Identity.Domain; -using FluentAssertions; - -namespace EntKube.Identity.Tests.Domain; - -public class CustomerTests -{ - [Fact] - public void Create_WithValidInputs_CreatesActiveCustomer() - { - // Arrange — A tenant admin onboards a new customer (team/organization) - // within their tenant. In the terraform reference, these are the "tenants" - // like capioDA, capioonline, volvat — each representing a team that gets - // its own namespaces, quotas, and app deployments per environment. - - Guid tenantId = Guid.NewGuid(); - - // Act - - Customer customer = Customer.Create(tenantId, "Capio Data Analytics", "capioda"); - - // Assert — The customer should be active and linked to its tenant. - - customer.Id.Should().NotBe(Guid.Empty); - customer.TenantId.Should().Be(tenantId); - customer.Name.Should().Be("Capio Data Analytics"); - customer.Slug.Should().Be("capioda"); - customer.Status.Should().Be(CustomerStatus.Active); - customer.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - } - - [Fact] - public void Create_NormalizesSlugToLowerCase() - { - // Arrange & Act — Slugs should be lowercase for consistent lookups. - - Customer customer = Customer.Create(Guid.NewGuid(), "Volvat", "VOLVAT"); - - // Assert - - customer.Slug.Should().Be("volvat"); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => Customer.Create(Guid.NewGuid(), "", "slug"); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptySlug_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => Customer.Create(Guid.NewGuid(), "Some Customer", ""); - - // Assert - - act.Should().Throw() - .WithParameterName("slug"); - } - - [Fact] - public void Create_WithEmptyTenantId_ThrowsArgumentException() - { - // Arrange & Act — Every customer must belong to a tenant. - - Action act = () => Customer.Create(Guid.Empty, "Some Customer", "slug"); - - // Assert - - act.Should().Throw() - .WithParameterName("tenantId"); - } - - [Fact] - public void Suspend_SetsStatusToSuspended() - { - // Arrange — An active customer that the admin wants to temporarily disable - // (e.g. during a billing dispute or compliance review). - - Customer customer = Customer.Create(Guid.NewGuid(), "Volvat", "volvat"); - - // Act - - customer.Suspend(); - - // Assert - - customer.Status.Should().Be(CustomerStatus.Suspended); - } - - [Fact] - public void Activate_SetsStatusToActive() - { - // Arrange — A previously suspended customer being re-enabled. - - Customer customer = Customer.Create(Guid.NewGuid(), "Volvat", "volvat"); - customer.Suspend(); - - // Act - - customer.Activate(); - - // Assert - - customer.Status.Should().Be(CustomerStatus.Active); - } -} diff --git a/tests/EntKube.Identity.Tests/Domain/EnvironmentTests.cs b/tests/EntKube.Identity.Tests/Domain/EnvironmentTests.cs deleted file mode 100644 index 0829292..0000000 --- a/tests/EntKube.Identity.Tests/Domain/EnvironmentTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -using EntKube.Identity.Domain; -using FluentAssertions; - -namespace EntKube.Identity.Tests.Domain; - -public class EnvironmentTests -{ - [Fact] - public void Create_WithValidInputs_CreatesActiveEnvironment() - { - // Arrange — A tenant admin creates a new environment (e.g. "dev") - // for their organization. The environment starts in an Active state - // and is ready to have clusters assigned to it. - - Guid tenantId = Guid.NewGuid(); - - // Act - - TenantEnvironment env = TenantEnvironment.Create(tenantId, "Development", "dev"); - - // Assert — The environment should be active and linked to its tenant. - - env.Id.Should().NotBe(Guid.Empty); - env.TenantId.Should().Be(tenantId); - env.Name.Should().Be("Development"); - env.Slug.Should().Be("dev"); - env.Status.Should().Be(EnvironmentStatus.Active); - env.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - } - - [Fact] - public void Create_NormalizesSlugToLowerCase() - { - // Arrange & Act — Slugs should always be lowercase for consistent lookups. - - TenantEnvironment env = TenantEnvironment.Create(Guid.NewGuid(), "Production", "PROD"); - - // Assert - - env.Slug.Should().Be("prod"); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => TenantEnvironment.Create(Guid.NewGuid(), "", "dev"); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptySlug_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => TenantEnvironment.Create(Guid.NewGuid(), "Development", ""); - - // Assert - - act.Should().Throw() - .WithParameterName("slug"); - } - - [Fact] - public void Create_WithEmptyTenantId_ThrowsArgumentException() - { - // Arrange & Act — Every environment must belong to a tenant. - - Action act = () => TenantEnvironment.Create(Guid.Empty, "Development", "dev"); - - // Assert - - act.Should().Throw() - .WithParameterName("tenantId"); - } - - [Fact] - public void Deactivate_SetsStatusToInactive() - { - // Arrange — An active environment that the admin wants to disable - // (e.g. decommissioning a staging environment). - - TenantEnvironment env = TenantEnvironment.Create(Guid.NewGuid(), "Staging", "staging"); - - // Act - - env.Deactivate(); - - // Assert - - env.Status.Should().Be(EnvironmentStatus.Inactive); - } - - [Fact] - public void Activate_SetsStatusToActive() - { - // Arrange — A previously deactivated environment being re-enabled. - - TenantEnvironment env = TenantEnvironment.Create(Guid.NewGuid(), "Staging", "staging"); - env.Deactivate(); - - // Act - - env.Activate(); - - // Assert - - env.Status.Should().Be(EnvironmentStatus.Active); - } -} diff --git a/tests/EntKube.Identity.Tests/Domain/TenantTests.cs b/tests/EntKube.Identity.Tests/Domain/TenantTests.cs deleted file mode 100644 index a9be29f..0000000 --- a/tests/EntKube.Identity.Tests/Domain/TenantTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using EntKube.Identity.Domain; -using FluentAssertions; - -namespace EntKube.Identity.Tests.Domain; - -public class TenantTests -{ - [Fact] - public void Create_WithValidInputs_CreatesTenantWithAdminMember() - { - // Arrange & Act — An admin creates a new tenant organization. - - Guid userId = Guid.NewGuid(); - Tenant tenant = Tenant.Create("Acme Corp", "acme-corp", userId); - - // Assert — The tenant should be active and the creator should be admin. - - tenant.Id.Should().NotBe(Guid.Empty); - tenant.Name.Should().Be("Acme Corp"); - tenant.Slug.Should().Be("acme-corp"); - tenant.Status.Should().Be(TenantStatus.Active); - tenant.Members.Should().HaveCount(1); - tenant.Members[0].UserId.Should().Be(userId); - tenant.Members[0].Role.Should().Be(TenantRole.Admin); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => Tenant.Create("", "slug", Guid.NewGuid()); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptySlug_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => Tenant.Create("Acme", "", Guid.NewGuid()); - - // Assert - - act.Should().Throw() - .WithParameterName("slug"); - } - - [Fact] - public void AddMember_NewUser_AddsMemberToTenant() - { - // Arrange - - Tenant tenant = Tenant.Create("Acme", "acme", Guid.NewGuid()); - Guid newUserId = Guid.NewGuid(); - - // Act - - tenant.AddMember(newUserId, TenantRole.Member); - - // Assert - - tenant.Members.Should().HaveCount(2); - tenant.Members.Should().Contain(m => m.UserId == newUserId && m.Role == TenantRole.Member); - } - - [Fact] - public void AddMember_ExistingUser_DoesNotDuplicate() - { - // Arrange — Create a tenant (creator is already a member). - - Guid userId = Guid.NewGuid(); - Tenant tenant = Tenant.Create("Acme", "acme", userId); - - // Act — Try adding the same user again. - - tenant.AddMember(userId, TenantRole.Viewer); - - // Assert — Still only one member. - - tenant.Members.Should().HaveCount(1); - } - - [Fact] - public void Suspend_SetsStatusToSuspended() - { - // Arrange - - Tenant tenant = Tenant.Create("Acme", "acme", Guid.NewGuid()); - - // Act - - tenant.Suspend(); - - // Assert - - tenant.Status.Should().Be(TenantStatus.Suspended); - } -} diff --git a/tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj b/tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj deleted file mode 100644 index 4cb2cc1..0000000 --- a/tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/EntKube.Identity.Tests/Features/GetTenantsHandlerTests.cs b/tests/EntKube.Identity.Tests/Features/GetTenantsHandlerTests.cs deleted file mode 100644 index 9e1a4eb..0000000 --- a/tests/EntKube.Identity.Tests/Features/GetTenantsHandlerTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using EntKube.Identity.Domain; -using EntKube.Identity.Features.GetTenants; -using EntKube.Identity.Infrastructure; -using FluentAssertions; - -namespace EntKube.Identity.Tests.Features; - -public class GetTenantsHandlerTests -{ - private readonly InMemoryTenantRepository repository; - - public GetTenantsHandlerTests() - { - repository = new InMemoryTenantRepository(); - } - - [Fact] - public async Task GetAllAsync_WithTenants_ReturnsList() - { - // Arrange — Two tenants exist in the repository. - - Tenant tenant1 = Tenant.Create("Acme Corp", "acme", Guid.NewGuid()); - Tenant tenant2 = Tenant.Create("Globex", "globex", Guid.NewGuid()); - - await repository.AddAsync(tenant1); - await repository.AddAsync(tenant2); - - // Act — Retrieve all tenants via the repository (endpoint uses it directly). - - IReadOnlyList tenants = await repository.GetAllAsync(); - - // Assert - - tenants.Should().HaveCount(2); - tenants.Should().Contain(t => t.Name == "Acme Corp"); - tenants.Should().Contain(t => t.Name == "Globex"); - } - - [Fact] - public async Task GetAllAsync_Empty_ReturnsEmptyList() - { - // Act - - IReadOnlyList tenants = await repository.GetAllAsync(); - - // Assert - - tenants.Should().BeEmpty(); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs b/tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs deleted file mode 100644 index 181dad3..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -public class AppEnvironmentTests -{ - // ─── ConfigureDeployment ───────────────────────────────────────────── - - [Fact] - public void ConfigureDeployment_WithValidSpec_SetsDeploymentSpec() - { - // Arrange — After adding an environment, the admin configures - // the deployment details: which container image, how many replicas, - // port mappings, and routing. This is the equivalent of writing - // deployment.yaml + service.yaml + httproute.yaml by hand. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - - DeploymentSpec spec = new( - Image: "registry.example.com/api", - Tag: "v1.2.3", - Replicas: 2, - ContainerPort: 8080, - ServicePort: 80, - HostName: "api.dev.example.com", - PathPrefix: "/", - EnvironmentVariables: new Dictionary { ["ASPNETCORE_ENVIRONMENT"] = "Development" }, - Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi")); - - // Act - - env.ConfigureDeployment(spec); - - // Assert - - env.DeploymentSpec.Should().NotBeNull(); - env.DeploymentSpec!.Image.Should().Be("registry.example.com/api"); - env.DeploymentSpec.Tag.Should().Be("v1.2.3"); - env.DeploymentSpec.Replicas.Should().Be(2); - env.DeploymentSpec.ContainerPort.Should().Be(8080); - env.DeploymentSpec.ServicePort.Should().Be(80); - env.DeploymentSpec.HostName.Should().Be("api.dev.example.com"); - env.DeploymentSpec.Resources.Should().NotBeNull(); - env.SyncStatus.Should().Be(AppSyncStatus.Pending, "changing config resets sync to pending"); - } - - // ─── ConfigureHelmRelease ──────────────────────────────────────────── - - [Fact] - public void ConfigureHelmRelease_WithValidSpec_SetsHelmSpec() - { - // Arrange — For a Helm-type app, the admin points to a Helm chart - // repository, selects the chart and version, and provides a values.yaml. - // This is the equivalent of `helm install` with custom values. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "redis-dev"); - - HelmReleaseSpec spec = new( - RepoUrl: "https://charts.bitnami.com/bitnami", - ChartName: "redis", - ChartVersion: "18.6.1", - ValuesYaml: "replica:\n replicaCount: 3"); - - // Act - - env.ConfigureHelmRelease(spec); - - // Assert - - env.HelmReleaseSpec.Should().NotBeNull(); - env.HelmReleaseSpec!.RepoUrl.Should().Be("https://charts.bitnami.com/bitnami"); - env.HelmReleaseSpec.ChartName.Should().Be("redis"); - env.HelmReleaseSpec.ChartVersion.Should().Be("18.6.1"); - env.HelmReleaseSpec.ValuesYaml.Should().Contain("replicaCount: 3"); - env.SyncStatus.Should().Be(AppSyncStatus.Pending); - } - - // ─── Secrets ───────────────────────────────────────────────────────── - - [Fact] - public void AddSecret_WithValidInputs_AddsSecretReference() - { - // Arrange — Secrets for an app environment are stored in the vault. - // Here we add a reference that maps a Kubernetes secret key name - // to the vault path where the actual secret value lives. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - - // Act - - env.AddSecret("DATABASE_URL", "database-connection-string"); - - // Assert - - env.Secrets.Should().HaveCount(1); - env.Secrets[0].Name.Should().Be("DATABASE_URL"); - env.Secrets[0].VaultKey.Should().Be("database-connection-string"); - env.SyncStatus.Should().Be(AppSyncStatus.Pending, "adding a secret resets sync"); - } - - [Fact] - public void AddSecret_DuplicateName_ThrowsInvalidOperation() - { - // Arrange — Each secret name must be unique within an environment. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - env.AddSecret("DATABASE_URL", "db-conn-string"); - - // Act - - Action act = () => env.AddSecret("DATABASE_URL", "another-path"); - - // Assert - - act.Should().Throw() - .WithMessage("*already exists*"); - } - - [Fact] - public void RemoveSecret_ExistingSecret_RemovesIt() - { - // Arrange - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - env.AddSecret("DATABASE_URL", "db-conn-string"); - - // Act - - env.RemoveSecret("DATABASE_URL"); - - // Assert - - env.Secrets.Should().BeEmpty(); - env.SyncStatus.Should().Be(AppSyncStatus.Pending); - } - - // ─── Sync Status ───────────────────────────────────────────────────── - - [Fact] - public void MarkSynced_UpdatesSyncStatus() - { - // Arrange — The reconciler successfully deployed the app. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - - // Act - - env.MarkSynced(); - - // Assert - - env.SyncStatus.Should().Be(AppSyncStatus.Synced); - env.LastSyncAt.Should().NotBeNull(); - } - - [Fact] - public void MarkError_SetsSyncStatusAndMessage() - { - // Arrange — Something went wrong during deployment. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - - // Act - - env.MarkError("ImagePullBackOff: registry.example.com/api:v1.2.3"); - - // Assert - - env.SyncStatus.Should().Be(AppSyncStatus.Error); - env.SyncStatusMessage.Should().Contain("ImagePullBackOff"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/AppTests.cs b/tests/EntKube.Provisioning.Tests/Domain/AppTests.cs deleted file mode 100644 index 94b2c3f..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/AppTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -public class AppTests -{ - // ─── Create ────────────────────────────────────────────────────────── - - [Fact] - public void Create_WithValidInputs_CreatesActiveApp() - { - // Arrange — A customer admin creates a new app (e.g. "frontend-portal") - // that will eventually be deployed across environments. The app starts - // in an Active state and is ready to have environments added. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - - // Act - - App app = App.Create(tenantId, customerId, "Frontend Portal", "frontend-portal", AppType.Deployment); - - // Assert — The app should be active and linked to its customer and tenant. - - app.Id.Should().NotBe(Guid.Empty); - app.TenantId.Should().Be(tenantId); - app.CustomerId.Should().Be(customerId); - app.Name.Should().Be("Frontend Portal"); - app.Slug.Should().Be("frontend-portal"); - app.Type.Should().Be(AppType.Deployment); - app.Status.Should().Be(AppStatus.Active); - app.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - app.Environments.Should().BeEmpty(); - } - - [Fact] - public void Create_NormalizesSlugToLowerCase() - { - // Arrange & Act — Slugs should always be lowercase for consistency. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "My App", "MY-APP", AppType.HelmChart); - - // Assert - - app.Slug.Should().Be("my-app"); - } - - [Fact] - public void Create_WithEmptyTenantId_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => App.Create(Guid.Empty, Guid.NewGuid(), "App", "app", AppType.Deployment); - - // Assert - - act.Should().Throw() - .WithParameterName("tenantId"); - } - - [Fact] - public void Create_WithEmptyCustomerId_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => App.Create(Guid.NewGuid(), Guid.Empty, "App", "app", AppType.Deployment); - - // Assert - - act.Should().Throw() - .WithParameterName("customerId"); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => App.Create(Guid.NewGuid(), Guid.NewGuid(), "", "app", AppType.Deployment); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptySlug_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "", AppType.Deployment); - - // Assert - - act.Should().Throw() - .WithParameterName("slug"); - } - - // ─── Suspend / Activate ────────────────────────────────────────────── - - [Fact] - public void Suspend_SetsStatusToSuspended() - { - // Arrange — An active app that the admin wants to pause deployments for. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment); - - // Act - - app.Suspend(); - - // Assert - - app.Status.Should().Be(AppStatus.Suspended); - } - - [Fact] - public void Activate_SetsStatusToActive() - { - // Arrange — A previously suspended app being re-enabled. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment); - app.Suspend(); - - // Act - - app.Activate(); - - // Assert - - app.Status.Should().Be(AppStatus.Active); - } - - // ─── AddEnvironment ────────────────────────────────────────────────── - - [Fact] - public void AddEnvironment_WithValidInputs_CreatesAppEnvironment() - { - // Arrange — After creating an app, the admin adds it to a specific - // environment (e.g. "dev"). This links the app to a cluster and - // namespace where it will be deployed. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API Service", "api-service", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - // Act - - AppEnvironment env = app.AddEnvironment(environmentId, clusterId, "api-dev"); - - // Assert — The environment should be created in Pending state. - - env.Id.Should().NotBe(Guid.Empty); - env.EnvironmentId.Should().Be(environmentId); - env.ClusterId.Should().Be(clusterId); - env.Namespace.Should().Be("api-dev"); - env.SyncStatus.Should().Be(AppSyncStatus.Pending); - app.Environments.Should().HaveCount(1); - } - - [Fact] - public void AddEnvironment_DuplicateEnvironmentId_ThrowsInvalidOperation() - { - // Arrange — An app can only be deployed to each environment once. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev"); - - // Act - - Action act = () => app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev-2"); - - // Assert - - act.Should().Throw() - .WithMessage("*already*"); - } - - [Fact] - public void AddEnvironment_WithEmptyNamespace_ThrowsArgumentException() - { - // Arrange & Act - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment); - Action act = () => app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), ""); - - // Assert - - act.Should().Throw() - .WithParameterName("ns"); - } - - // ─── RemoveEnvironment ─────────────────────────────────────────────── - - [Fact] - public void RemoveEnvironment_ExistingEnvironment_RemovesIt() - { - // Arrange — An app deployed to dev that the admin wants to remove. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev"); - - // Act - - app.RemoveEnvironment(environmentId); - - // Assert - - app.Environments.Should().BeEmpty(); - } - - [Fact] - public void RemoveEnvironment_NonExistent_ThrowsInvalidOperation() - { - // Arrange & Act - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment); - Action act = () => app.RemoveEnvironment(Guid.NewGuid()); - - // Assert - - act.Should().Throw() - .WithMessage("*not found*"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs b/tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs deleted file mode 100644 index b96b177..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs +++ /dev/null @@ -1,360 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -public class GrafanaInstanceTests -{ - private readonly Guid environmentId = Guid.NewGuid(); - private readonly Guid clusterId = Guid.NewGuid(); - - // ─── Creation ─────────────────────────────────────────────────────── - - [Fact] - public void Create_WithValidInputs_CreatesInstanceInPendingState() - { - // Arrange & Act — An operator provisions a Grafana instance for an environment. - // Grafana is per-environment: one centralized visualization for all clusters - // in that environment, deployed to a specific cluster. - - GrafanaInstance instance = GrafanaInstance.Create( - environmentId, clusterId, "prod-grafana", "monitoring"); - - // Assert — The instance should be in Pending state with sensible defaults. - - instance.Id.Should().NotBe(Guid.Empty); - instance.EnvironmentId.Should().Be(environmentId); - instance.ClusterId.Should().Be(clusterId); - instance.Name.Should().Be("prod-grafana"); - instance.Namespace.Should().Be("monitoring"); - instance.Status.Should().Be(GrafanaInstanceStatus.Pending); - instance.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - - // Grafana defaults - - instance.Persistence.Should().BeFalse(); - instance.PersistenceSize.Should().Be("10Gi"); - instance.Oidc.Should().BeNull(); - instance.Ingress.Should().BeNull(); - } - - [Fact] - public void Create_WithEmptyEnvironmentId_ThrowsArgumentException() - { - // Arrange & Act — Grafana must belong to an environment. - - Action act = () => GrafanaInstance.Create(Guid.Empty, clusterId, "prod-grafana", "monitoring"); - - // Assert - - act.Should().Throw() - .WithParameterName("environmentId"); - } - - [Fact] - public void Create_WithEmptyClusterId_ThrowsArgumentException() - { - // Arrange & Act — Grafana must be deployed to a specific cluster. - - Action act = () => GrafanaInstance.Create(environmentId, Guid.Empty, "prod-grafana", "monitoring"); - - // Assert - - act.Should().Throw() - .WithParameterName("clusterId"); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => GrafanaInstance.Create(environmentId, clusterId, "", "monitoring"); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptyNamespace_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", ""); - - // Assert - - act.Should().Throw() - .WithParameterName("ns"); - } - - // ─── Persistence Configuration ────────────────────────────────── - - [Fact] - public void ConfigurePersistence_UpdatesSettings() - { - // Arrange — An operator wants Grafana to keep dashboards across restarts. - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act - - instance.ConfigurePersistence(enabled: true, size: "20Gi"); - - // Assert - - instance.Persistence.Should().BeTrue(); - instance.PersistenceSize.Should().Be("20Gi"); - } - - // ─── OIDC Configuration ───────────────────────────────────────── - - [Fact] - public void ConfigureOidc_SetsOidcProvider() - { - // Arrange — Integrate Grafana with an OIDC provider (e.g., Entra ID) - // so users authenticate with corporate credentials. - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act - - OidcConfiguration oidc = new( - Provider: "generic_oauth", - ClientId: "grafana-client-id", - ClientSecretRef: "vault://monitoring/grafana-oidc-client-secret", - AuthUrl: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize", - TokenUrl: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token", - ApiUrl: "https://graph.microsoft.com/oidc/userinfo", - Scopes: "openid profile email", - RoleAttributePath: "contains(groups[*], 'admin-group-id') && 'Admin' || 'Viewer'", - AutoLogin: true); - - instance.ConfigureOidc(oidc); - - // Assert - - instance.Oidc.Should().NotBeNull(); - instance.Oidc!.Provider.Should().Be("generic_oauth"); - instance.Oidc.ClientId.Should().Be("grafana-client-id"); - instance.Oidc.ClientSecretRef.Should().Be("vault://monitoring/grafana-oidc-client-secret"); - instance.Oidc.AutoLogin.Should().BeTrue(); - } - - [Fact] - public void ConfigureOidc_WithEmptyClientId_ThrowsArgumentException() - { - // Arrange - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act & Assert - - OidcConfiguration oidc = new( - Provider: "generic_oauth", - ClientId: "", - ClientSecretRef: "vault://secret", - AuthUrl: "https://example.com/auth", - TokenUrl: "https://example.com/token", - ApiUrl: "https://example.com/userinfo", - Scopes: "openid", - RoleAttributePath: null, - AutoLogin: false); - - Action act = () => instance.ConfigureOidc(oidc); - - act.Should().Throw(); - } - - [Fact] - public void RemoveOidc_ClearsOidcConfiguration() - { - // Arrange — A Grafana instance with OIDC configured. - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - OidcConfiguration oidc = new( - Provider: "generic_oauth", - ClientId: "client-id", - ClientSecretRef: "vault://secret", - AuthUrl: "https://example.com/auth", - TokenUrl: "https://example.com/token", - ApiUrl: "https://example.com/userinfo", - Scopes: "openid", - RoleAttributePath: null, - AutoLogin: false); - - instance.ConfigureOidc(oidc); - - // Act — Remove OIDC, fall back to local auth. - - instance.RemoveOidc(); - - // Assert - - instance.Oidc.Should().BeNull(); - } - - // ─── Ingress ──────────────────────────────────────────────────── - - [Fact] - public void ConfigureIngress_SetsGrafanaRouting() - { - // Arrange — Publish Grafana externally with its own hostname. - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act - - GrafanaIngressConfiguration ingress = new( - Enabled: true, - Provider: IngressProvider.GatewayApi, - Hostname: "grafana.prod01.example.com", - TlsEnabled: true, - TlsCertificateMode: TlsCertificateMode.CertManager, - TlsSecretName: null, - ClusterIssuer: "letsencrypt-prod"); - - instance.ConfigureIngress(ingress); - - // Assert - - instance.Ingress.Should().NotBeNull(); - instance.Ingress!.Enabled.Should().BeTrue(); - instance.Ingress.Provider.Should().Be(IngressProvider.GatewayApi); - instance.Ingress.Hostname.Should().Be("grafana.prod01.example.com"); - instance.Ingress.TlsCertificateMode.Should().Be(TlsCertificateMode.CertManager); - instance.Ingress.ClusterIssuer.Should().Be("letsencrypt-prod"); - } - - [Fact] - public void DisableIngress_ClearsIngressConfig() - { - // Arrange - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - GrafanaIngressConfiguration ingress = new( - Enabled: true, - Provider: IngressProvider.GatewayApi, - Hostname: "grafana.example.com", - TlsEnabled: true, - TlsCertificateMode: TlsCertificateMode.Manual, - TlsSecretName: "grafana-tls"); - - instance.ConfigureIngress(ingress); - - // Act - - instance.DisableIngress(); - - // Assert - - instance.Ingress.Should().BeNull(); - } - - // ─── Dashboards ───────────────────────────────────────────────── - - [Fact] - public void AddDashboard_StoresDashboardDefinition() - { - // Arrange — Dashboards are JSON definitions deployed as ConfigMaps - // with the grafana_dashboard=1 label so the sidecar picks them up. - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act — Add a Kubernetes cluster dashboard. - - GrafanaDashboard dashboard = new( - Name: "k8s-cluster", - DisplayName: "Kubernetes Cluster", - Category: DashboardCategory.Kubernetes, - JsonContent: """{"dashboard": {"title": "K8s Cluster"}}"""); - - instance.AddDashboard(dashboard); - - // Assert - - instance.Dashboards.Should().HaveCount(1); - instance.Dashboards[0].Name.Should().Be("k8s-cluster"); - instance.Dashboards[0].Category.Should().Be(DashboardCategory.Kubernetes); - } - - [Fact] - public void AddDashboard_WithDuplicateName_ThrowsInvalidOperationException() - { - // Arrange - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - GrafanaDashboard dashboard = new( - Name: "k8s-cluster", - DisplayName: "Kubernetes Cluster", - Category: DashboardCategory.Kubernetes, - JsonContent: """{"dashboard": {}}"""); - - instance.AddDashboard(dashboard); - - // Act & Assert — Can't add two dashboards with the same name. - - Action act = () => instance.AddDashboard(dashboard); - - act.Should().Throw(); - } - - [Fact] - public void RemoveDashboard_DeletesByName() - { - // Arrange - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - instance.AddDashboard(new GrafanaDashboard("k8s-cluster", "Kubernetes Cluster", DashboardCategory.Kubernetes, "{}")); - instance.AddDashboard(new GrafanaDashboard("istio-mesh", "Istio Mesh", DashboardCategory.Istio, "{}")); - - // Act - - instance.RemoveDashboard("k8s-cluster"); - - // Assert - - instance.Dashboards.Should().HaveCount(1); - instance.Dashboards[0].Name.Should().Be("istio-mesh"); - } - - // ─── Lifecycle ────────────────────────────────────────────────── - - [Fact] - public void MarkRunning_SetsStatusToRunning() - { - // Arrange - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act - - instance.MarkRunning(); - - // Assert - - instance.Status.Should().Be(GrafanaInstanceStatus.Running); - } - - [Fact] - public void MarkDegraded_SetsStatusToDegraded() - { - // Arrange - - GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring"); - - // Act - - instance.MarkDegraded(); - - // Assert - - instance.Status.Should().Be(GrafanaInstanceStatus.Degraded); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs b/tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs deleted file mode 100644 index 6f73e48..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -public class IdentityRealmTests -{ - [Fact] - public void Create_ValidName_ReturnsRealmInProvisioningState() - { - // When creating a new realm, it starts in Provisioning status - // with the given name and cluster association. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - IdentityRealm realm = IdentityRealm.Create(environmentId, "my-app", clusterId); - - realm.Id.Should().NotBeEmpty(); - realm.EnvironmentId.Should().Be(environmentId); - realm.ClusterId.Should().Be(clusterId); - realm.Name.Should().Be("my-app"); - realm.Status.Should().Be(IdentityRealmStatus.Provisioning); - realm.Branding.Should().Be(RealmBranding.Default); - realm.Pages.LoginEnabled.Should().BeTrue(); - realm.Pages.ProfileEnabled.Should().BeFalse(); - } - - [Fact] - public void Create_EmptyName_ThrowsArgumentException() - { - // A realm must have a name — empty is not valid. - - Action act = () => IdentityRealm.Create(Guid.NewGuid(), "", Guid.NewGuid()); - - act.Should().Throw(); - } - - [Fact] - public void UpdateBranding_ChangesBrandingAndTimestamp() - { - // After updating branding, the new values stick and - // the last modified timestamp is updated. - - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "branded", Guid.NewGuid()); - - RealmBranding newBranding = new( - LogoUrl: "https://example.com/logo.png", - BackgroundImageUrl: null, - BackgroundColor: "#1a1a2e", - PrimaryColor: "#16213e", - SecondaryColor: "#0f3460", - TextColor: "#ffffff"); - - realm.UpdateBranding(newBranding); - - realm.Branding.LogoUrl.Should().Be("https://example.com/logo.png"); - realm.Branding.BackgroundColor.Should().Be("#1a1a2e"); - realm.LastModifiedAt.Should().NotBeNull(); - } - - [Fact] - public void UpdatePages_EnablesProfile() - { - // A realm starts with only login enabled. We can enable profile pages too. - - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "with-profile", Guid.NewGuid()); - - RealmPages pages = new( - LoginEnabled: true, - ProfileEnabled: true, - RegistrationEnabled: false, - ForgotPasswordEnabled: true); - - realm.UpdatePages(pages); - - realm.Pages.ProfileEnabled.Should().BeTrue(); - } - - [Fact] - public void AddIdentityProvider_NewProvider_AddsToList() - { - // Adding an OIDC identity provider to the realm. - - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "idp-realm", Guid.NewGuid()); - - RealmIdentityProvider provider = RealmIdentityProvider.Create( - alias: "azure-ad", - displayName: "Azure AD", - type: IdentityProviderType.Oidc, - authorizationUrl: "https://login.microsoftonline.com/authorize", - tokenUrl: "https://login.microsoftonline.com/token", - clientId: "my-client-id"); - - realm.AddIdentityProvider(provider); - - realm.IdentityProviders.Should().HaveCount(1); - realm.IdentityProviders[0].Alias.Should().Be("azure-ad"); - realm.IdentityProviders[0].Type.Should().Be(IdentityProviderType.Oidc); - } - - [Fact] - public void AddIdentityProvider_DuplicateAlias_Throws() - { - // Each provider must have a unique alias within the realm. - - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "dup-realm", Guid.NewGuid()); - - RealmIdentityProvider first = RealmIdentityProvider.Create("github", "GitHub", IdentityProviderType.GitHub); - RealmIdentityProvider duplicate = RealmIdentityProvider.Create("github", "GitHub Copy", IdentityProviderType.GitHub); - - realm.AddIdentityProvider(first); - - Action act = () => realm.AddIdentityProvider(duplicate); - - act.Should().Throw(); - } - - [Fact] - public void RemoveIdentityProvider_ExistingAlias_RemovesFromList() - { - // Removing an identity provider by alias. - - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "remove-realm", Guid.NewGuid()); - - RealmIdentityProvider provider = RealmIdentityProvider.Create("google", "Google", IdentityProviderType.Google); - realm.AddIdentityProvider(provider); - - realm.RemoveIdentityProvider("google"); - - realm.IdentityProviders.Should().BeEmpty(); - } - - [Fact] - public void AddOrganization_NewOrg_AddsToList() - { - // Organizations let you group users within a realm. - - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "org-realm", Guid.NewGuid()); - - RealmOrganization org = RealmOrganization.Create("Acme Corp", "Top-level org"); - - realm.AddOrganization(org); - - realm.Organizations.Should().HaveCount(1); - realm.Organizations[0].Name.Should().Be("Acme Corp"); - } - - [Fact] - public void Organization_AddChild_CreatesHierarchy() - { - // Organizations support nested children for hierarchical structure. - - RealmOrganization parent = RealmOrganization.Create("Acme Corp"); - RealmOrganization child = RealmOrganization.Create("Engineering"); - RealmOrganization grandchild = RealmOrganization.Create("Backend Team"); - - parent.AddChild(child); - child.AddChild(grandchild); - - parent.Children.Should().HaveCount(1); - parent.Children[0].Name.Should().Be("Engineering"); - parent.Children[0].Children.Should().HaveCount(1); - parent.Children[0].Children[0].Name.Should().Be("Backend Team"); - } - - [Fact] - public void Organization_DuplicateChildName_Throws() - { - // Child names must be unique within a parent. - - RealmOrganization parent = RealmOrganization.Create("Acme Corp"); - RealmOrganization child1 = RealmOrganization.Create("Engineering"); - RealmOrganization child2 = RealmOrganization.Create("Engineering"); - - parent.AddChild(child1); - - Action act = () => parent.AddChild(child2); - - act.Should().Throw(); - } - - [Fact] - public void MarkActive_ChangesStatusToActive() - { - IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "active-realm", Guid.NewGuid()); - - realm.MarkActive(); - - realm.Status.Should().Be(IdentityRealmStatus.Active); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs b/tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs deleted file mode 100644 index 7626c96..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs +++ /dev/null @@ -1,204 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -/// -/// Tests for the MinioInstance aggregate. This tracks adopted MinIO deployments -/// that the platform uses as backup targets for CNPG clusters. -/// -public class MinioInstanceTests -{ - [Fact] - public void Adopt_WithValidInputs_CreatesInstanceInRunningState() - { - // Arrange & Act — Adopt an existing MinIO deployment discovered on the cluster. - - Guid clusterId = Guid.NewGuid(); - - MinioInstance minio = MinioInstance.Adopt( - clusterId: clusterId, - name: "platform-minio", - ns: "minio-system", - endpoint: "minio.minio-system.svc:9000", - consoleEndpoint: "minio-console.minio-system.svc:9001", - credentialsSecret: "minio-root-credentials", - totalCapacity: "500Gi", - usedCapacity: "120Gi", - buckets: new List { "cnpg-backups", "loki-logs", "velero" }); - - // Assert — Adopted instances are already running. - - minio.Id.Should().NotBe(Guid.Empty); - minio.ClusterId.Should().Be(clusterId); - minio.Name.Should().Be("platform-minio"); - minio.Namespace.Should().Be("minio-system"); - minio.Endpoint.Should().Be("minio.minio-system.svc:9000"); - minio.ConsoleEndpoint.Should().Be("minio-console.minio-system.svc:9001"); - minio.CredentialsSecret.Should().Be("minio-root-credentials"); - minio.Status.Should().Be(MinioInstanceStatus.Running); - minio.Buckets.Should().HaveCount(3); - minio.Buckets.Should().Contain("cnpg-backups"); - } - - [Fact] - public void Adopt_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => MinioInstance.Adopt( - Guid.NewGuid(), "", "ns", "endpoint:9000", null, - "secret", "500Gi", null, null); - - // Assert - - act.Should().Throw().WithParameterName("name"); - } - - [Fact] - public void Adopt_WithEmptyEndpoint_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => MinioInstance.Adopt( - Guid.NewGuid(), "minio", "ns", "", null, - "secret", "500Gi", null, null); - - // Assert - - act.Should().Throw().WithParameterName("endpoint"); - } - - [Fact] - public void Adopt_WithEmptyCredentials_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => MinioInstance.Adopt( - Guid.NewGuid(), "minio", "ns", "endpoint:9000", null, - "", "500Gi", null, null); - - // Assert - - act.Should().Throw().WithParameterName("credentialsSecret"); - } - - [Fact] - public void CreateBucket_WhenRunning_AddsBucketToList() - { - // Arrange — A running MinIO instance. - - MinioInstance minio = CreateTestInstance(); - - // Act — CNPG provisioning requests a new bucket for WAL storage. - - EntKube.SharedKernel.Domain.Result result = minio.CreateBucket("new-pg-backups"); - - // Assert - - result.IsSuccess.Should().BeTrue(); - minio.Buckets.Should().Contain("new-pg-backups"); - } - - [Fact] - public void CreateBucket_DuplicateName_ReturnsFailure() - { - // Arrange - - MinioInstance minio = MinioInstance.Adopt( - Guid.NewGuid(), "minio", "ns", "minio:9000", null, - "secret", "500Gi", null, - new List { "existing-bucket" }); - - // Act - - EntKube.SharedKernel.Domain.Result result = minio.CreateBucket("existing-bucket"); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("already exists"); - } - - [Fact] - public void CreateBucket_WhenDegraded_ReturnsFailure() - { - // Arrange — A degraded MinIO can't serve new bucket requests. - - MinioInstance minio = CreateTestInstance(); - minio.MarkDegraded("Storage pool full"); - - // Act - - EntKube.SharedKernel.Domain.Result result = minio.CreateBucket("new-bucket"); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("running"); - } - - [Fact] - public void MarkDegraded_SetsStatusAndMessage() - { - // Arrange - - MinioInstance minio = CreateTestInstance(); - - // Act - - minio.MarkDegraded("Drive offline"); - - // Assert - - minio.Status.Should().Be(MinioInstanceStatus.Degraded); - minio.StatusMessage.Should().Be("Drive offline"); - } - - [Fact] - public void MarkRunning_ClearsDegradedStatus() - { - // Arrange — A previously degraded instance recovers. - - MinioInstance minio = CreateTestInstance(); - minio.MarkDegraded("Temporary issue"); - - // Act - - minio.MarkRunning(); - - // Assert - - minio.Status.Should().Be(MinioInstanceStatus.Running); - minio.StatusMessage.Should().BeNull(); - } - - [Fact] - public void GetBackupCredentials_ReturnsEndpointAndSecret() - { - // Arrange — CNPG needs to know where to send backups. - - MinioInstance minio = MinioInstance.Adopt( - Guid.NewGuid(), "minio", "minio-system", "minio.minio-system.svc:9000", null, - "minio-root-credentials", "1Ti", null, null); - - // Act - - MinioBackupTarget target = minio.GetBackupTarget("cnpg-backups"); - - // Assert - - target.Endpoint.Should().Be("minio.minio-system.svc:9000"); - target.Bucket.Should().Be("cnpg-backups"); - target.CredentialsSecret.Should().Be("minio-root-credentials"); - } - - private static MinioInstance CreateTestInstance() - { - return MinioInstance.Adopt( - Guid.NewGuid(), "platform-minio", "minio-system", - "minio.minio-system.svc:9000", "minio-console:9001", - "minio-root-credentials", "500Gi", "120Gi", - new List { "cnpg-backups" }); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs b/tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs deleted file mode 100644 index a466d2a..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs +++ /dev/null @@ -1,307 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -/// -/// Tests for the MinioTenant aggregate. A MinIO Tenant represents an actual storage -/// cluster managed by the MinIO Operator on Kubernetes. Each tenant has one or more -/// pools, each pool defining how many servers and disks to use. -/// -/// One MinIO Operator installation can host multiple tenants — each tenant is a -/// separate storage cluster with its own credentials, namespace, and capacity. -/// -public class MinioTenantTests -{ - [Fact] - public void Create_WithValidInputs_CreatesTenantInProvisioningState() - { - // Arrange — A platform admin wants to provision a new MinIO storage tenant - // on a cluster. They specify how much capacity they need: 4 servers, each - // with 4 disks of 100Gi. - - Guid clusterId = Guid.NewGuid(); - List pools = new() - { - new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }; - - // Act - - MinioTenant tenant = MinioTenant.Create( - clusterId: clusterId, - name: "platform-storage", - ns: "minio-tenant-1", - pools: pools); - - // Assert — New tenants start in Provisioning state until the operator confirms. - - tenant.Id.Should().NotBe(Guid.Empty); - tenant.ClusterId.Should().Be(clusterId); - tenant.Name.Should().Be("platform-storage"); - tenant.Namespace.Should().Be("minio-tenant-1"); - tenant.Status.Should().Be(MinioTenantStatus.Provisioning); - tenant.Pools.Should().HaveCount(1); - tenant.Pools[0].Servers.Should().Be(4); - tenant.Pools[0].VolumesPerServer.Should().Be(4); - tenant.Pools[0].StorageSize.Should().Be("100Gi"); - tenant.Pools[0].StorageClass.Should().Be("ceph-block"); - tenant.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - } - - [Fact] - public void Create_WithMultiplePools_TracksAllPools() - { - // Arrange — Some deployments need heterogeneous pools: - // one for hot data (fast NVMe) and one for warm data (spinning disk). - - List pools = new() - { - new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"), - new MinioTenantPool(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk") - }; - - // Act - - MinioTenant tenant = MinioTenant.Create(Guid.NewGuid(), "multi-pool", "minio-mp", pools); - - // Assert - - tenant.Pools.Should().HaveCount(2); - tenant.Pools[0].StorageClass.Should().Be("nvme-fast"); - tenant.Pools[1].StorageClass.Should().Be("hdd-bulk"); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - List pools = new() { new MinioTenantPool(4, 4, "100Gi", "standard") }; - - Action act = () => MinioTenant.Create(Guid.NewGuid(), "", "ns", pools); - - // Assert - - act.Should().Throw().WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptyNamespace_ThrowsArgumentException() - { - // Arrange & Act - - List pools = new() { new MinioTenantPool(4, 4, "100Gi", "standard") }; - - Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "", pools); - - // Assert - - act.Should().Throw().WithParameterName("ns"); - } - - [Fact] - public void Create_WithNoPools_ThrowsArgumentException() - { - // Arrange & Act — A tenant must have at least one pool to be meaningful. - - Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", new List()); - - // Assert - - act.Should().Throw().WithParameterName("pools"); - } - - [Fact] - public void Create_WithZeroServers_ThrowsArgumentException() - { - // Arrange & Act — Each pool needs at least one server. - - List pools = new() { new MinioTenantPool(0, 4, "100Gi", "standard") }; - - Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", pools); - - // Assert - - act.Should().Throw(); - } - - [Fact] - public void Create_WithZeroVolumes_ThrowsArgumentException() - { - // Arrange & Act — Each pool needs at least one volume per server. - - List pools = new() { new MinioTenantPool(4, 0, "100Gi", "standard") }; - - Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", pools); - - // Assert - - act.Should().Throw(); - } - - [Fact] - public void MarkRunning_TransitionsFromProvisioning() - { - // Arrange — After the operator creates the tenant, the reconciler confirms it's running. - - MinioTenant tenant = CreateTestTenant(); - - // Act - - tenant.MarkRunning(); - - // Assert - - tenant.Status.Should().Be(MinioTenantStatus.Running); - tenant.StatusMessage.Should().BeNull(); - } - - [Fact] - public void MarkDegraded_SetsStatusAndReason() - { - // Arrange - - MinioTenant tenant = CreateTestTenant(); - tenant.MarkRunning(); - - // Act - - tenant.MarkDegraded("1 drive offline"); - - // Assert - - tenant.Status.Should().Be(MinioTenantStatus.Degraded); - tenant.StatusMessage.Should().Be("1 drive offline"); - } - - [Fact] - public void UpdatePools_ReplacesPoolConfiguration() - { - // Arrange — The admin wants to scale out: add more servers to the pool. - - MinioTenant tenant = CreateTestTenant(); - tenant.MarkRunning(); - - List newPools = new() - { - new MinioTenantPool(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }; - - // Act - - tenant.UpdatePools(newPools); - - // Assert — Status goes back to Provisioning while the operator reconciles. - - tenant.Pools.Should().HaveCount(1); - tenant.Pools[0].Servers.Should().Be(8); - tenant.Status.Should().Be(MinioTenantStatus.Provisioning); - } - - [Fact] - public void UpdatePools_WithEmptyPools_ThrowsArgumentException() - { - // Arrange - - MinioTenant tenant = CreateTestTenant(); - tenant.MarkRunning(); - - // Act & Assert - - Action act = () => tenant.UpdatePools(new List()); - act.Should().Throw(); - } - - [Fact] - public void Decommission_MarksAsDecommissioned() - { - // Arrange — The admin tears down a tenant. The operator will delete resources. - - MinioTenant tenant = CreateTestTenant(); - tenant.MarkRunning(); - - // Act - - tenant.Decommission(); - - // Assert - - tenant.Status.Should().Be(MinioTenantStatus.Decommissioned); - } - - [Fact] - public void GetEndpoint_ReturnsConventionalServiceUrl() - { - // Arrange — MinIO tenants expose storage at -hl..svc:9000 - - MinioTenant tenant = CreateTestTenant(); - - // Act - - string endpoint = tenant.GetEndpoint(); - - // Assert - - endpoint.Should().Be("platform-storage-hl.minio-tenant-1.svc:9000"); - } - - [Fact] - public void GetConsoleEndpoint_ReturnsConventionalConsoleUrl() - { - // Arrange - - MinioTenant tenant = CreateTestTenant(); - - // Act - - string consoleEndpoint = tenant.GetConsoleEndpoint(); - - // Assert - - consoleEndpoint.Should().Be("platform-storage-console.minio-tenant-1.svc:9443"); - } - - [Fact] - public void GetCredentialsSecretName_ReturnsConventionalSecretName() - { - // Arrange - - MinioTenant tenant = CreateTestTenant(); - - // Act - - string secret = tenant.GetCredentialsSecretName(); - - // Assert - - secret.Should().Be("platform-storage-secret"); - } - - [Fact] - public void TotalCapacity_CalculatesFromPoolsAsString() - { - // Arrange — Capacity = servers * volumesPerServer * storageSize (per pool). - // With 4 servers × 4 volumes × 100Gi = 1600Gi total. - - MinioTenant tenant = CreateTestTenant(); - - // Act - - string capacity = tenant.CalculateTotalCapacity(); - - // Assert — 4 × 4 × 100 = 1600Gi - - capacity.Should().Be("1600Gi"); - } - - private static MinioTenant CreateTestTenant() - { - List pools = new() - { - new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }; - - return MinioTenant.Create(Guid.NewGuid(), "platform-storage", "minio-tenant-1", pools); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs b/tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs deleted file mode 100644 index 439164e..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs +++ /dev/null @@ -1,405 +0,0 @@ -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -/// -/// Tests for the MongoCluster aggregate root. This rich domain model tracks -/// everything about a MongoDB Community Operator cluster: replica set members, -/// storage, backup configuration, databases, users, and version lifecycle. -/// -/// MongoDB Community Operator manages MongoDBCommunity CRDs on Kubernetes. -/// Each cluster is a replica set with configurable members, storage, and -/// authentication. Backup is handled via scheduled mongodump jobs to MinIO. -/// -public class MongoClusterTests -{ - // ─── Creation ────────────────────────────────────────────────────────────── - - [Fact] - public void Create_WithValidInputs_ReturnsClusterInProvisioningState() - { - // Arrange & Act — A platform admin provisions a new MongoDB cluster - // for shared use on a specific Kubernetes cluster. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create( - environmentId: environmentId, - clusterId: clusterId, - name: "platform-mongo", - ns: "mongodb-system", - mongoVersion: "7.0.12", - members: 3, - storageSize: "50Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "mongo-backups", - minioCredentialsSecret: "minio-mongo-credentials"); - - // Assert — The cluster starts in Provisioning state, waiting for the - // reconciler to create the MongoDBCommunity resource. - - mongoCluster.Id.Should().NotBe(Guid.Empty); - mongoCluster.EnvironmentId.Should().Be(environmentId); - mongoCluster.ClusterId.Should().Be(clusterId); - mongoCluster.Name.Should().Be("platform-mongo"); - mongoCluster.Namespace.Should().Be("mongodb-system"); - mongoCluster.MongoVersion.Should().Be("7.0.12"); - mongoCluster.Members.Should().Be(3); - mongoCluster.StorageSize.Should().Be("50Gi"); - mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Provisioning); - mongoCluster.Databases.Should().BeEmpty(); - } - - [Fact] - public void Create_WithMinIOBackupConfig_SetsBackupConfiguration() - { - // Arrange & Act — Backup to MinIO is configured at creation time - // for mongodump-based backups. Unlike CNPG WAL archiving, MongoDB - // uses periodic full dumps. - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "prod-mongo", - ns: "mongodb-prod", - mongoVersion: "7.0.12", - members: 3, - storageSize: "100Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "mongo-dump-archive", - minioCredentialsSecret: "minio-mongo-creds"); - - // Assert — Backup configuration is stored as part of the aggregate. - - mongoCluster.BackupConfig.Should().NotBeNull(); - mongoCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000"); - mongoCluster.BackupConfig.Bucket.Should().Be("mongo-dump-archive"); - mongoCluster.BackupConfig.CredentialsSecret.Should().Be("minio-mongo-creds"); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act — The domain rejects invalid cluster names. - - Action act = () => Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "", "ns", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - - // Assert - - act.Should().Throw().WithParameterName("name"); - } - - [Fact] - public void Create_WithZeroMembers_ThrowsArgumentException() - { - // Arrange & Act — Must have at least 1 replica set member. - - Action act = () => Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 0, "50Gi", - "minio:9000", "bucket", "secret"); - - // Assert - - act.Should().Throw().WithParameterName("members"); - } - - [Fact] - public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup() - { - // Arrange & Act — Backup is optional. When MinIO endpoint is empty, - // the cluster should be created successfully without backup config. - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 3, "50Gi", - "", "bucket", "secret"); - - // Assert — cluster exists but has no backup configuration. - - mongoCluster.Should().NotBeNull(); - mongoCluster.BackupConfig.Should().BeNull(); - } - - // ─── Adoption ────────────────────────────────────────────────────────────── - - [Fact] - public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig() - { - // Arrange & Act — When we adopt an existing MongoDBCommunity cluster - // that's already running on K8s, we create it in Running state. - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Adopt( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "existing-mongo", - ns: "mongodb-system", - mongoVersion: "6.0.16", - members: 2, - storageSize: "20Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "existing-backups", - minioCredentialsSecret: "existing-minio-creds", - databases: new List { "app_db", "analytics_db" }); - - // Assert — Adopted clusters start in Running state and include - // any databases that were already present. - - mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running); - mongoCluster.Databases.Should().HaveCount(2); - mongoCluster.Databases.Should().Contain("app_db"); - mongoCluster.Databases.Should().Contain("analytics_db"); - } - - // ─── Status transitions ──────────────────────────────────────────────────── - - [Fact] - public void MarkRunning_TransitionsFromProvisioning() - { - // Arrange — A freshly created cluster in Provisioning state. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - - // Act — The reconciler confirmed the cluster is ready. - - mongoCluster.MarkRunning(); - - // Assert - - mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running); - mongoCluster.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public void MarkDegraded_SetsStatusToDegraded() - { - // Arrange - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - mongoCluster.MarkRunning(); - - // Act — Reconciler detected an issue. - - mongoCluster.MarkDegraded("Replica set member not ready"); - - // Assert - - mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Degraded); - mongoCluster.StatusMessage.Should().Be("Replica set member not ready"); - } - - // ─── Database management ─────────────────────────────────────────────────── - - [Fact] - public void AddDatabase_WhenRunning_AddsDatabaseToList() - { - // Arrange — A running cluster can have databases added. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - mongoCluster.MarkRunning(); - - // Act — An application requests a new database. - - Result result = mongoCluster.AddDatabase("my_app_db", "app_user"); - - // Assert - - result.IsSuccess.Should().BeTrue(); - mongoCluster.Databases.Should().Contain("my_app_db"); - } - - [Fact] - public void AddDatabase_WhenNotRunning_ReturnsFailure() - { - // Arrange — A cluster still provisioning can't accept new databases. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - - // Act - - Result result = mongoCluster.AddDatabase("my_db", "user"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("running"); - } - - [Fact] - public void AddDatabase_DuplicateName_ReturnsFailure() - { - // Arrange — A running cluster with one database already. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - mongoCluster.MarkRunning(); - mongoCluster.AddDatabase("existing_db", "user1"); - - // Act — Try to add a database with the same name. - - Result result = mongoCluster.AddDatabase("existing_db", "user2"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("already exists"); - } - - // ─── Version upgrades ────────────────────────────────────────────────────── - - [Fact] - public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion() - { - // Arrange — A running cluster at version 6.0.16. - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "6.0.16", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - - // Act — Platform admin requests upgrade to 7.0.12. - - Result result = mongoCluster.RequestUpgrade("7.0.12"); - - // Assert — The cluster transitions to Upgrading state. - - result.IsSuccess.Should().BeTrue(); - mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Upgrading); - mongoCluster.TargetVersion.Should().Be("7.0.12"); - } - - [Fact] - public void RequestUpgrade_WhenNotRunning_ReturnsFailure() - { - // Arrange — A cluster still provisioning can't be upgraded. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - - // Act - - Result result = mongoCluster.RequestUpgrade("7.0.12"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("running"); - } - - [Fact] - public void RequestUpgrade_ToSameVersion_ReturnsFailure() - { - // Arrange — A cluster already at version 7.0.12. - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - - // Act — Try to upgrade to the same version. - - Result result = mongoCluster.RequestUpgrade("7.0.12"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("same version"); - } - - [Fact] - public void CompleteUpgrade_TransitionsToRunningWithNewVersion() - { - // Arrange — A cluster mid-upgrade. - - Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "6.0.16", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - mongoCluster.RequestUpgrade("7.0.12"); - - // Act — Reconciler confirmed upgrade completed. - - mongoCluster.CompleteUpgrade(); - - // Assert — Version updated, status back to Running. - - mongoCluster.MongoVersion.Should().Be("7.0.12"); - mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running); - mongoCluster.TargetVersion.Should().BeNull(); - } - - // ─── Scaling ─────────────────────────────────────────────────────────────── - - [Fact] - public void ScaleMembers_UpdatesMemberCount() - { - // Arrange — A running 3-member cluster. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - mongoCluster.MarkRunning(); - - // Act — Scale up to 5 members for more redundancy. - - Result result = mongoCluster.ScaleMembers(5); - - // Assert - - result.IsSuccess.Should().BeTrue(); - mongoCluster.Members.Should().Be(5); - } - - [Fact] - public void ScaleMembers_ToZero_ReturnsFailure() - { - // Arrange - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - mongoCluster.MarkRunning(); - - // Act - - Result result = mongoCluster.ScaleMembers(0); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("at least 1"); - } - - // ─── Backup configuration ────────────────────────────────────────────────── - - [Fact] - public void ConfigureBackupSchedule_UpdatesRetentionPolicy() - { - // Arrange — A running cluster needs its backup schedule updated. - - Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster(); - mongoCluster.MarkRunning(); - - // Act — Set backup to run every 6 hours with 30-day retention. - - mongoCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30); - - // Assert - - mongoCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); - mongoCluster.BackupConfig.RetentionDays.Should().Be(30); - } - - // ─── Helper ──────────────────────────────────────────────────────────────── - - private static Provisioning.Domain.MongoCluster CreateTestCluster() - { - return Provisioning.Domain.MongoCluster.Create( - Guid.NewGuid(), - Guid.NewGuid(), - "test-mongo", - "mongodb-system", - "7.0.12", - 3, - "50Gi", - "minio.minio-system.svc:9000", - "mongo-backups", - "minio-mongo-credentials"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs b/tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs deleted file mode 100644 index 4d6fd64..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs +++ /dev/null @@ -1,437 +0,0 @@ -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -/// -/// Tests for the PostgresCluster aggregate root. This rich domain model tracks -/// everything about a CloudNativePG PostgreSQL cluster: instances, storage, -/// WAL archiving, backup configuration, databases, and version lifecycle. -/// -public class PostgresClusterTests -{ - [Fact] - public void Create_WithValidInputs_ReturnsClusterInProvisioningState() - { - // Arrange & Act — A platform admin provisions a new PostgreSQL cluster - // for shared use on a specific Kubernetes cluster. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - environmentId: environmentId, - clusterId: clusterId, - name: "platform-db", - ns: "cnpg-system", - postgresVersion: "16.4", - instances: 3, - storageSize: "50Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "cnpg-backups", - minioCredentialsSecret: "minio-cnpg-credentials"); - - // Assert — The cluster starts in Provisioning state, waiting for the - // reconciler to actually create the CNPG Cluster resource. - - pgCluster.Id.Should().NotBe(Guid.Empty); - pgCluster.EnvironmentId.Should().Be(environmentId); - pgCluster.ClusterId.Should().Be(clusterId); - pgCluster.Name.Should().Be("platform-db"); - pgCluster.Namespace.Should().Be("cnpg-system"); - pgCluster.PostgresVersion.Should().Be("16.4"); - pgCluster.Instances.Should().Be(3); - pgCluster.StorageSize.Should().Be("50Gi"); - pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Provisioning); - pgCluster.Databases.Should().BeEmpty(); - } - - [Fact] - public void Create_WithMinIOBackupConfig_SetsWalArchiving() - { - // Arrange & Act — WAL archiving to MinIO is configured at creation time - // since it's a hard dependency. The cluster won't be created without it. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "prod-db", - ns: "cnpg-prod", - postgresVersion: "16.4", - instances: 3, - storageSize: "100Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "pg-wal-archive", - minioCredentialsSecret: "minio-pg-creds"); - - // Assert — Backup configuration is stored as part of the aggregate. - - pgCluster.BackupConfig.Should().NotBeNull(); - pgCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000"); - pgCluster.BackupConfig.Bucket.Should().Be("pg-wal-archive"); - pgCluster.BackupConfig.CredentialsSecret.Should().Be("minio-pg-creds"); - pgCluster.BackupConfig.WalArchivingEnabled.Should().BeTrue(); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act — The domain rejects invalid cluster names. - - Action act = () => Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "", "ns", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - - // Assert - - act.Should().Throw().WithParameterName("name"); - } - - [Fact] - public void Create_WithZeroInstances_ThrowsArgumentException() - { - // Arrange & Act — Must have at least 1 instance. - - Action act = () => Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 0, "50Gi", - "minio:9000", "bucket", "secret"); - - // Assert - - act.Should().Throw().WithParameterName("instances"); - } - - [Fact] - public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup() - { - // Arrange & Act — Backup is optional. When MinIO endpoint is empty, - // the cluster should be created successfully without backup config. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi", - "", "bucket", "secret"); - - // Assert — cluster exists but has no backup configuration. - - pgCluster.Should().NotBeNull(); - pgCluster.BackupConfig.Should().BeNull(); - } - - [Fact] - public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig() - { - // Arrange & Act — When we adopt an existing CNPG cluster that's already - // running on the K8s cluster, we create it in Running state with the - // discovered configuration. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Adopt( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "existing-db", - ns: "cnpg-system", - postgresVersion: "15.6", - instances: 2, - storageSize: "20Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "existing-backups", - minioCredentialsSecret: "existing-minio-creds", - databases: new List { "app_db", "analytics_db" }); - - // Assert — Adopted clusters start in Running state and include - // any databases that were already present. - - pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running); - pgCluster.Databases.Should().HaveCount(2); - pgCluster.Databases.Should().Contain("app_db"); - pgCluster.Databases.Should().Contain("analytics_db"); - } - - [Fact] - public void MarkRunning_TransitionsFromProvisioning() - { - // Arrange — A freshly created cluster in Provisioning state. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - - // Act — The reconciler confirmed the cluster is ready. - - pgCluster.MarkRunning(); - - // Assert - - pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running); - pgCluster.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public void AddDatabase_WhenRunning_AddsDatabaseToList() - { - // Arrange — A running cluster can have databases added to it. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - pgCluster.MarkRunning(); - - // Act — An application requests a new database be provisioned. - - Result result = pgCluster.AddDatabase("my_app_db", "app_user"); - - // Assert - - result.IsSuccess.Should().BeTrue(); - pgCluster.Databases.Should().Contain("my_app_db"); - } - - [Fact] - public void AddDatabase_WhenNotRunning_ReturnsFailure() - { - // Arrange — A cluster that's still provisioning can't accept new databases. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - - // Act - - Result result = pgCluster.AddDatabase("my_db", "user"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("running"); - } - - [Fact] - public void AddDatabase_DuplicateName_ReturnsFailure() - { - // Arrange — A running cluster with one database already. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - pgCluster.MarkRunning(); - pgCluster.AddDatabase("existing_db", "user1"); - - // Act — Try to add a database with the same name. - - Result result = pgCluster.AddDatabase("existing_db", "user2"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("already exists"); - } - - [Fact] - public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion() - { - // Arrange — A running cluster at version 15.6. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - - // Act — Platform admin requests upgrade to 16.4. - - Result result = pgCluster.RequestUpgrade("16.4"); - - // Assert — The cluster transitions to Upgrading state with the target version recorded. - - result.IsSuccess.Should().BeTrue(); - pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Upgrading); - pgCluster.TargetVersion.Should().Be("16.4"); - } - - [Fact] - public void RequestUpgrade_WhenNotRunning_ReturnsFailure() - { - // Arrange — A cluster still provisioning can't be upgraded. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - - // Act - - Result result = pgCluster.RequestUpgrade("16.4"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("running"); - } - - [Fact] - public void RequestUpgrade_ToSameVersion_ReturnsFailure() - { - // Arrange — A cluster already at version 16.4. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - - // Act — Try to upgrade to the same version. - - Result result = pgCluster.RequestUpgrade("16.4"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("same version"); - } - - [Fact] - public void CompleteUpgrade_TransitionsToRunningWithNewVersion() - { - // Arrange — A cluster that's mid-upgrade. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - pgCluster.RequestUpgrade("16.4"); - - // Act — The reconciler confirmed the upgrade completed. - - pgCluster.CompleteUpgrade(); - - // Assert — Version updated, status back to Running. - - pgCluster.PostgresVersion.Should().Be("16.4"); - pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running); - pgCluster.TargetVersion.Should().BeNull(); - } - - [Fact] - public void ScaleInstances_UpdatesInstanceCount() - { - // Arrange — A running 3-instance cluster. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - pgCluster.MarkRunning(); - - // Act — Scale up to 5 instances for more read replicas. - - Result result = pgCluster.ScaleInstances(5); - - // Assert - - result.IsSuccess.Should().BeTrue(); - pgCluster.Instances.Should().Be(5); - } - - [Fact] - public void ScaleInstances_ToZero_ReturnsFailure() - { - // Arrange - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - pgCluster.MarkRunning(); - - // Act - - Result result = pgCluster.ScaleInstances(0); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("at least 1"); - } - - [Fact] - public void ConfigureBackupSchedule_UpdatesRetentionPolicy() - { - // Arrange — A running cluster needs its backup schedule updated. - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - pgCluster.MarkRunning(); - - // Act — Set backup to run every 6 hours with 30-day retention. - - pgCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30); - - // Assert - - pgCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); - pgCluster.BackupConfig.RetentionDays.Should().Be(30); - } - - [Fact] - public void MarkDegraded_SetsStatusToDegraded() - { - // Arrange - - Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster(); - pgCluster.MarkRunning(); - - // Act — Reconciler detected an issue. - - pgCluster.MarkDegraded("Primary pod not ready"); - - // Assert - - pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Degraded); - pgCluster.StatusMessage.Should().Be("Primary pod not ready"); - } - - [Fact] - public void Create_WithS3Region_StoresRegionInBackupConfig() - { - // Arrange & Act — When creating a CNPG cluster backed by an external S3 - // provider like Cleura, the region must be captured so barman-cloud can - // use it for AWS Signature V4 authentication. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "cleura-db", - ns: "cnpg-prod", - postgresVersion: "16.4", - instances: 3, - storageSize: "100Gi", - minioEndpoint: "https://s3.cleura.cloud", - minioBucket: "cnpg-backups", - minioCredentialsSecret: "cnpg-s3-creds", - s3Region: "eu-north-1"); - - // Assert — The region is stored in the backup configuration so it can - // be included in the CNPG manifest's s3Credentials.region field. - - pgCluster.BackupConfig.Should().NotBeNull(); - pgCluster.BackupConfig!.S3Region.Should().Be("eu-north-1"); - } - - [Fact] - public void Create_WithoutS3Region_DefaultsToNull() - { - // Arrange & Act — MinIO endpoints don't need a region. When omitted, - // S3Region should be null and barman-cloud will skip region config. - - Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "minio-db", - ns: "cnpg-system", - postgresVersion: "16.4", - instances: 3, - storageSize: "50Gi", - minioEndpoint: "minio.minio-system.svc:9000", - minioBucket: "cnpg-backups", - minioCredentialsSecret: "minio-creds"); - - // Assert - - pgCluster.BackupConfig.Should().NotBeNull(); - pgCluster.BackupConfig!.S3Region.Should().BeNull(); - } - - private static Provisioning.Domain.PostgresCluster CreateTestCluster() - { - return Provisioning.Domain.PostgresCluster.Create( - Guid.NewGuid(), - Guid.NewGuid(), - "test-db", - "cnpg-system", - "16.4", - 3, - "50Gi", - "minio.minio-system.svc:9000", - "cnpg-backups", - "minio-cnpg-credentials"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs b/tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs deleted file mode 100644 index 2a27f90..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs +++ /dev/null @@ -1,275 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -public class PrometheusStackTests -{ - private readonly Guid clusterId = Guid.NewGuid(); - private readonly Guid environmentId = Guid.NewGuid(); - - // ─── Creation ─────────────────────────────────────────────────────── - - [Fact] - public void Create_WithValidInputs_CreatesStackInPendingState() - { - // Arrange & Act — An operator provisions a new Prometheus stack on a cluster. - // Prometheus is per-cluster: each cluster gets its own metrics collector. - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Assert — The stack should be in a Pending state, with sensible defaults - // for Prometheus retention, replicas, and Alertmanager. - - stack.Id.Should().NotBe(Guid.Empty); - stack.ClusterId.Should().Be(clusterId); - stack.EnvironmentId.Should().Be(environmentId); - stack.Name.Should().Be("prod-prometheus"); - stack.Namespace.Should().Be("monitoring"); - stack.Status.Should().Be(PrometheusStackStatus.Pending); - stack.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - - // Prometheus defaults - - stack.Prometheus.Should().NotBeNull(); - stack.Prometheus.Retention.Should().Be("30d"); - stack.Prometheus.Replicas.Should().Be(2); - stack.Prometheus.StorageSize.Should().Be("50Gi"); - - // Alertmanager defaults - - stack.Alertmanager.Should().NotBeNull(); - stack.Alertmanager.Enabled.Should().BeTrue(); - stack.Alertmanager.Replicas.Should().Be(2); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act — A Prometheus stack must have a name. - - Action act = () => PrometheusStack.Create(clusterId, environmentId, "", "monitoring"); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void Create_WithEmptyNamespace_ThrowsArgumentException() - { - // Arrange & Act — A Prometheus stack must have a namespace. - - Action act = () => PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", ""); - - // Assert - - act.Should().Throw() - .WithParameterName("ns"); - } - - [Fact] - public void Create_WithEmptyClusterId_ThrowsArgumentException() - { - // Arrange & Act — A Prometheus stack must belong to a cluster. - - Action act = () => PrometheusStack.Create(Guid.Empty, environmentId, "prod-prometheus", "monitoring"); - - // Assert - - act.Should().Throw() - .WithParameterName("clusterId"); - } - - [Fact] - public void Create_WithEmptyEnvironmentId_ThrowsArgumentException() - { - // Arrange & Act — A Prometheus stack must belong to an environment. - - Action act = () => PrometheusStack.Create(clusterId, Guid.Empty, "prod-prometheus", "monitoring"); - - // Assert - - act.Should().Throw() - .WithParameterName("environmentId"); - } - - // ─── Prometheus Configuration ───────────────────────────────────── - - [Fact] - public void ConfigurePrometheus_UpdatesRetentionAndReplicas() - { - // Arrange — Start with a Prometheus stack using defaults. - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act — The operator increases retention to 90 days and scales to 3 replicas. - - stack.ConfigurePrometheus(retention: "90d", replicas: 3, storageSize: "100Gi", retentionSize: "90GB"); - - // Assert — Prometheus settings should reflect the new configuration. - - stack.Prometheus.Retention.Should().Be("90d"); - stack.Prometheus.Replicas.Should().Be(3); - stack.Prometheus.StorageSize.Should().Be("100Gi"); - stack.Prometheus.RetentionSize.Should().Be("90GB"); - } - - [Fact] - public void ConfigurePrometheus_WithInvalidReplicas_ThrowsArgumentException() - { - // Arrange - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act & Assert — Zero replicas doesn't make sense for Prometheus. - - Action act = () => stack.ConfigurePrometheus(replicas: 0); - - act.Should().Throw() - .WithParameterName("replicas"); - } - - // ─── Alertmanager Configuration ───────────────────────────────── - - [Fact] - public void ConfigureAlertmanager_UpdatesReplicasAndEnablement() - { - // Arrange - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act — The operator disables Alertmanager for a dev cluster. - - stack.ConfigureAlertmanager(enabled: false, replicas: 1); - - // Assert - - stack.Alertmanager.Enabled.Should().BeFalse(); - stack.Alertmanager.Replicas.Should().Be(1); - } - - // ─── Ingress (Prometheus + Alertmanager) ──────────────────────── - - [Fact] - public void ConfigureIngress_SetsPrometheusAndAlertmanagerRouting() - { - // Arrange — Publish Prometheus and Alertmanager via Gateway API. - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act - - IngressConfiguration ingress = new( - Enabled: true, - Provider: IngressProvider.GatewayApi, - PrometheusHostname: "prometheus.prod01.example.com", - AlertmanagerHostname: "alertmanager.prod01.example.com", - TlsEnabled: true, - TlsCertificateMode: TlsCertificateMode.Manual, - TlsSecretName: "monitoring-tls"); - - stack.ConfigureIngress(ingress); - - // Assert - - stack.Ingress.Should().NotBeNull(); - stack.Ingress!.Enabled.Should().BeTrue(); - stack.Ingress.Provider.Should().Be(IngressProvider.GatewayApi); - stack.Ingress.PrometheusHostname.Should().Be("prometheus.prod01.example.com"); - stack.Ingress.AlertmanagerHostname.Should().Be("alertmanager.prod01.example.com"); - stack.Ingress.TlsEnabled.Should().BeTrue(); - stack.Ingress.TlsCertificateMode.Should().Be(TlsCertificateMode.Manual); - stack.Ingress.TlsSecretName.Should().Be("monitoring-tls"); - } - - [Fact] - public void ConfigureIngress_WithCertManager_DoesNotRequireSecretName() - { - // Arrange - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act — cert-manager with Let's Encrypt handles certificates automatically. - - IngressConfiguration ingress = new( - Enabled: true, - Provider: IngressProvider.GatewayApi, - PrometheusHostname: "prometheus.example.com", - AlertmanagerHostname: "alertmanager.example.com", - TlsEnabled: true, - TlsCertificateMode: TlsCertificateMode.CertManager, - TlsSecretName: null, - ClusterIssuer: "letsencrypt-prod"); - - stack.ConfigureIngress(ingress); - - // Assert - - stack.Ingress!.TlsCertificateMode.Should().Be(TlsCertificateMode.CertManager); - stack.Ingress.TlsSecretName.Should().BeNull(); - stack.Ingress.ClusterIssuer.Should().Be("letsencrypt-prod"); - } - - [Fact] - public void DisableIngress_ClearsIngressConfig() - { - // Arrange - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - IngressConfiguration ingress = new( - Enabled: true, - Provider: IngressProvider.GatewayApi, - PrometheusHostname: "prometheus.example.com", - AlertmanagerHostname: null, - TlsEnabled: true, - TlsCertificateMode: TlsCertificateMode.Manual, - TlsSecretName: "tls-secret"); - - stack.ConfigureIngress(ingress); - - // Act - - stack.DisableIngress(); - - // Assert - - stack.Ingress.Should().BeNull(); - } - - // ─── Lifecycle ────────────────────────────────────────────────── - - [Fact] - public void MarkRunning_SetsStatusToRunning() - { - // Arrange - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act - - stack.MarkRunning(); - - // Assert - - stack.Status.Should().Be(PrometheusStackStatus.Running); - } - - [Fact] - public void MarkDegraded_SetsStatusToDegraded() - { - // Arrange - - PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring"); - - // Act - - stack.MarkDegraded(); - - // Assert - - stack.Status.Should().Be(PrometheusStackStatus.Degraded); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs b/tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs deleted file mode 100644 index c43f048..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs +++ /dev/null @@ -1,305 +0,0 @@ -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -/// -/// Tests for the RedisCluster aggregate root. This rich domain model tracks -/// everything about a Redis instance managed by the OT-OPERATORS Redis Operator: -/// replica count, storage, sentinel mode, persistence, and version lifecycle. -/// -/// The OT-OPERATORS Redis Operator manages Redis, RedisCluster, RedisReplication, -/// and RedisSentinel CRDs on Kubernetes. Each Redis instance can be standalone, -/// replicated, or run with Sentinel for HA. -/// -public class RedisClusterTests -{ - // ─── Creation ────────────────────────────────────────────────────────────── - - [Fact] - public void Create_WithValidInputs_ReturnsClusterInProvisioningState() - { - // Arrange & Act — A platform admin provisions a new Redis instance - // for shared caching on a specific Kubernetes cluster. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create( - environmentId: environmentId, - clusterId: clusterId, - name: "platform-redis", - ns: "redis", - redisVersion: "7.2.5", - replicas: 3, - storageSize: "10Gi", - sentinelEnabled: true); - - // Assert — The cluster starts in Provisioning state, waiting for the - // reconciler to create the Redis CR. - - redisCluster.Id.Should().NotBe(Guid.Empty); - redisCluster.EnvironmentId.Should().Be(environmentId); - redisCluster.ClusterId.Should().Be(clusterId); - redisCluster.Name.Should().Be("platform-redis"); - redisCluster.Namespace.Should().Be("redis"); - redisCluster.RedisVersion.Should().Be("7.2.5"); - redisCluster.Replicas.Should().Be(3); - redisCluster.StorageSize.Should().Be("10Gi"); - redisCluster.SentinelEnabled.Should().BeTrue(); - redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Provisioning); - } - - [Fact] - public void Create_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act — The domain rejects invalid cluster names. - - Action act = () => Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "", "ns", "7.2.5", 3, "10Gi", true); - - // Assert - - act.Should().Throw().WithParameterName("name"); - } - - [Fact] - public void Create_WithZeroReplicas_ThrowsArgumentException() - { - // Arrange & Act — Must have at least 1 replica. - - Action act = () => Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 0, "10Gi", true); - - // Assert - - act.Should().Throw().WithParameterName("replicas"); - } - - [Fact] - public void Create_WithEmptyNamespace_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "", "7.2.5", 3, "10Gi", true); - - // Assert - - act.Should().Throw().WithParameterName("ns"); - } - - // ─── Adoption ────────────────────────────────────────────────────────────── - - [Fact] - public void Adopt_FromExistingInstance_CreatesRunningCluster() - { - // Arrange & Act — When we adopt an existing Redis instance that's - // already running on the K8s cluster, we create it in Running state. - - Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Adopt( - environmentId: Guid.NewGuid(), - clusterId: Guid.NewGuid(), - name: "existing-redis", - ns: "redis", - redisVersion: "7.0.15", - replicas: 3, - storageSize: "5Gi", - sentinelEnabled: true); - - // Assert — Adopted clusters start in Running state. - - redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running); - redisCluster.Name.Should().Be("existing-redis"); - redisCluster.LastReconcileAt.Should().NotBeNull(); - } - - // ─── Status transitions ──────────────────────────────────────────────────── - - [Fact] - public void MarkRunning_TransitionsFromProvisioning() - { - // Arrange — A freshly created cluster in Provisioning state. - - Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster(); - - // Act — The reconciler confirmed the Redis instance is ready. - - redisCluster.MarkRunning(); - - // Assert - - redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running); - redisCluster.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public void MarkDegraded_SetsStatusToDegraded() - { - // Arrange - - Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster(); - redisCluster.MarkRunning(); - - // Act — Reconciler detected an issue. - - redisCluster.MarkDegraded("Replica pod not ready"); - - // Assert - - redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Degraded); - redisCluster.StatusMessage.Should().Be("Replica pod not ready"); - } - - // ─── Version upgrades ────────────────────────────────────────────────────── - - [Fact] - public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion() - { - // Arrange — A running cluster at version 7.0.15. - - Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.0.15", 3, "10Gi", true); - redisCluster.MarkRunning(); - - // Act — Platform admin requests upgrade to 7.2.5. - - Result result = redisCluster.RequestUpgrade("7.2.5"); - - // Assert — The cluster transitions to Upgrading state. - - result.IsSuccess.Should().BeTrue(); - redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Upgrading); - redisCluster.TargetVersion.Should().Be("7.2.5"); - } - - [Fact] - public void RequestUpgrade_WhenNotRunning_ReturnsFailure() - { - // Arrange — A cluster still provisioning can't be upgraded. - - Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster(); - - // Act - - Result result = redisCluster.RequestUpgrade("7.2.5"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("running"); - } - - [Fact] - public void RequestUpgrade_ToSameVersion_ReturnsFailure() - { - // Arrange — A cluster already at version 7.2.5. - - Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 3, "10Gi", true); - redisCluster.MarkRunning(); - - // Act — Try to upgrade to the same version. - - Result result = redisCluster.RequestUpgrade("7.2.5"); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("same version"); - } - - [Fact] - public void CompleteUpgrade_TransitionsToRunningWithNewVersion() - { - // Arrange — A cluster mid-upgrade. - - Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.0.15", 3, "10Gi", true); - redisCluster.MarkRunning(); - redisCluster.RequestUpgrade("7.2.5"); - - // Act — Reconciler confirmed upgrade completed. - - redisCluster.CompleteUpgrade(); - - // Assert — Version updated, status back to Running. - - redisCluster.RedisVersion.Should().Be("7.2.5"); - redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running); - redisCluster.TargetVersion.Should().BeNull(); - } - - // ─── Scaling ─────────────────────────────────────────────────────────────── - - [Fact] - public void ScaleReplicas_UpdatesReplicaCount() - { - // Arrange — A running 3-replica cluster. - - Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster(); - redisCluster.MarkRunning(); - - // Act — Scale up to 5 replicas for more read capacity. - - Result result = redisCluster.ScaleReplicas(5); - - // Assert - - result.IsSuccess.Should().BeTrue(); - redisCluster.Replicas.Should().Be(5); - } - - [Fact] - public void ScaleReplicas_ToZero_ReturnsFailure() - { - // Arrange - - Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster(); - redisCluster.MarkRunning(); - - // Act - - Result result = redisCluster.ScaleReplicas(0); - - // Assert - - result.IsSuccess.Should().BeFalse(); - result.Error.Should().Contain("at least 1"); - } - - // ─── Configuration ───────────────────────────────────────────────────────── - - [Fact] - public void ConfigureSentinel_EnablesOrDisablesSentinel() - { - // Arrange — A cluster without sentinel. - - Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 3, "10Gi", false); - redisCluster.MarkRunning(); - - // Act — Enable sentinel for HA. - - redisCluster.ConfigureSentinel(true); - - // Assert - - redisCluster.SentinelEnabled.Should().BeTrue(); - } - - // ─── Helper ──────────────────────────────────────────────────────────────── - - private static Provisioning.Domain.RedisCluster CreateTestCluster() - { - return Provisioning.Domain.RedisCluster.Create( - Guid.NewGuid(), - Guid.NewGuid(), - "test-redis", - "redis", - "7.2.5", - 3, - "10Gi", - true); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Domain/ServiceInstanceTests.cs b/tests/EntKube.Provisioning.Tests/Domain/ServiceInstanceTests.cs deleted file mode 100644 index 3d9bfae..0000000 --- a/tests/EntKube.Provisioning.Tests/Domain/ServiceInstanceTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using EntKube.Provisioning.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Domain; - -public class ServiceInstanceTests -{ - [Fact] - public void Provision_WithValidInputs_CreatesInstanceInPendingState() - { - // Arrange & Act — Request provisioning of a MinIO instance. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - ServiceInstance instance = ServiceInstance.Provision( - environmentId, - ServiceType.MinIO, - "tenant-storage", - "minio-system", - clusterId); - - // Assert — Starts pending until the reconciler deploys it. - - instance.Id.Should().NotBe(Guid.Empty); - instance.EnvironmentId.Should().Be(environmentId); - instance.ClusterId.Should().Be(clusterId); - instance.ServiceType.Should().Be(ServiceType.MinIO); - instance.Name.Should().Be("tenant-storage"); - instance.Namespace.Should().Be("minio-system"); - instance.DesiredState.Should().Be(ServiceState.Running); - instance.CurrentState.Should().Be(ServiceState.Pending); - } - - [Fact] - public void Provision_WithEmptyName_ThrowsArgumentException() - { - // Arrange & Act - - Action act = () => ServiceInstance.Provision(Guid.NewGuid(), ServiceType.MinIO, "", "ns", Guid.NewGuid()); - - // Assert - - act.Should().Throw() - .WithParameterName("name"); - } - - [Fact] - public void MarkRunning_UpdatesCurrentState() - { - // Arrange - - ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.CloudNativePG, "db", "cnpg-system", Guid.NewGuid()); - - // Act - - instance.MarkRunning(); - - // Assert - - instance.CurrentState.Should().Be(ServiceState.Running); - instance.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public void RequestDecommission_SetsDesiredStateToDecommissioned() - { - // Arrange - - ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.Keycloak, "auth", "keycloak-system", Guid.NewGuid()); - instance.MarkRunning(); - - // Act — Tenant admin decides to tear down this service. - - instance.RequestDecommission(); - - // Assert — Desired state changes but current state remains until reconciler acts. - - instance.DesiredState.Should().Be(ServiceState.Decommissioned); - instance.CurrentState.Should().Be(ServiceState.Running); - } - - [Fact] - public void ServiceType_IncludesGitea() - { - // Gitea is a shared service that can be provisioned on a cluster, - // so ServiceType must include it as a valid option. - - ServiceType type = ServiceType.Gitea; - type.Should().BeDefined(); - } - - [Fact] - public void ServiceType_IncludesHarbor() - { - // Harbor is a shared container registry that can be provisioned - // on a cluster, so ServiceType must include it. - - ServiceType type = ServiceType.Harbor; - type.Should().BeDefined(); - } - - [Fact] - public void Provision_Gitea_CreatesInstanceInPendingState() - { - // Gitea should be provisionable just like any other service type. - - ServiceInstance instance = ServiceInstance.Provision( - Guid.NewGuid(), ServiceType.Gitea, "team-gitea", "gitea", Guid.NewGuid()); - - instance.ServiceType.Should().Be(ServiceType.Gitea); - instance.CurrentState.Should().Be(ServiceState.Pending); - } - - [Fact] - public void Provision_Harbor_CreatesInstanceInPendingState() - { - // Harbor should be provisionable just like any other service type. - - ServiceInstance instance = ServiceInstance.Provision( - Guid.NewGuid(), ServiceType.Harbor, "team-harbor", "harbor", Guid.NewGuid()); - - instance.ServiceType.Should().Be(ServiceType.Harbor); - instance.CurrentState.Should().Be(ServiceState.Pending); - } -} diff --git a/tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj b/tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj deleted file mode 100644 index cb6f54e..0000000 --- a/tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/EntKube.Provisioning.Tests/Features/AddAppEnvironmentHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/AddAppEnvironmentHandlerTests.cs deleted file mode 100644 index 84d2ced..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/AddAppEnvironmentHandlerTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.AddAppEnvironment; -using EntKube.Provisioning.Features.Apps.Reconcile; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -public class AddAppEnvironmentHandlerTests -{ - private readonly AddAppEnvironmentHandler handler; - private readonly InMemoryAppRepository repository; - private readonly Mock applyClientMock; - - public AddAppEnvironmentHandlerTests() - { - repository = new InMemoryAppRepository(); - applyClientMock = new Mock(); - handler = new AddAppEnvironmentHandler(repository, applyClientMock.Object); - } - - [Fact] - public async Task HandleAsync_WithValidRequest_AddsEnvironmentToApp() - { - // Arrange — An app exists and the admin wants to add it to the dev environment. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - await repository.AddAsync(app); - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - AddAppEnvironmentRequest request = new( - AppId: app.Id, - EnvironmentId: environmentId, - ClusterId: clusterId, - Namespace: "api-dev"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — The environment is added and its ID returned. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments.Should().HaveCount(1); - updated.Environments[0].EnvironmentId.Should().Be(environmentId); - updated.Environments[0].ClusterId.Should().Be(clusterId); - updated.Environments[0].Namespace.Should().Be("api-dev"); - - // Verify that a namespace manifest was applied to the cluster. - - applyClientMock.Verify( - c => c.ApplyManifestAsync(clusterId, It.Is(y => y.Contains("kind: Namespace") && y.Contains("name: api-dev")), It.IsAny()), - Times.Once); - } - - [Fact] - public async Task HandleAsync_WithNonExistentApp_ReturnsFailure() - { - // Arrange & Act - - AddAppEnvironmentRequest request = new(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "ns"); - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_WithDuplicateEnvironment_ReturnsFailure() - { - // Arrange — The app is already deployed to this environment. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev"); - await repository.AddAsync(app); - - AddAppEnvironmentRequest request = new(app.Id, environmentId, Guid.NewGuid(), "api-dev-2"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("already"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/AddAppSecretHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/AddAppSecretHandlerTests.cs deleted file mode 100644 index 6fbb40d..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/AddAppSecretHandlerTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.AddAppSecret; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class AddAppSecretHandlerTests -{ - private readonly AddAppSecretHandler handler; - private readonly InMemoryAppRepository repository; - - public AddAppSecretHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new AddAppSecretHandler(repository); - } - - [Fact] - public async Task HandleAsync_WithValidRequest_AddsSecretToEnvironment() - { - // Arrange — An app deployed to dev needs a database connection string. - // The actual secret value lives in the vault — we just store the reference. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev"); - await repository.AddAsync(app); - - AddAppSecretRequest request = new( - AppId: app.Id, - EnvironmentId: environmentId, - Name: "DATABASE_URL", - VaultKey: "database-connection-string"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - App? updated = await repository.GetByIdAsync(app.Id); - AppEnvironment env = updated!.Environments[0]; - env.Secrets.Should().HaveCount(1); - env.Secrets[0].Name.Should().Be("DATABASE_URL"); - env.Secrets[0].VaultKey.Should().Be("database-connection-string"); - } - - [Fact] - public async Task HandleAsync_NonExistentApp_ReturnsFailure() - { - // Arrange & Act - - AddAppSecretRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "KEY", "vault-key"); - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_DuplicateSecretName_ReturnsFailure() - { - // Arrange — A secret with this name already exists. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev"); - env.AddSecret("DATABASE_URL", "db-conn"); - await repository.AddAsync(app); - - AddAppSecretRequest request = new(app.Id, environmentId, "DATABASE_URL", "other-key"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("already exists"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs b/tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs deleted file mode 100644 index 2edcd39..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs +++ /dev/null @@ -1,387 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.Reconcile; -using EntKube.Provisioning.Infrastructure; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -public class AppReconcilerTests -{ - private readonly InMemoryAppRepository repository; - private readonly Mock applyClient; - private readonly Mock secretResolver; - private readonly Mock gatewayResolver; - private readonly AppReconcileHandler handler; - - public AppReconcilerTests() - { - repository = new InMemoryAppRepository(); - applyClient = new Mock(); - secretResolver = new Mock(); - gatewayResolver = new Mock(); - handler = new AppReconcileHandler(repository, applyClient.Object, secretResolver.Object, gatewayResolver.Object); - } - - // ─── Deployment-type app reconciliation ────────────────────────────── - - [Fact] - public async Task ReconcileAsync_DeploymentApp_AppliesAllManifests() - { - // Arrange — A Deployment-type app with a configured environment. - // The reconciler should generate and apply deployment.yaml, service.yaml, - // and httproute.yaml to the target cluster. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "registry.example.com/api", - Tag: "v1.0.0", - Replicas: 2, - ContainerPort: 8080, - ServicePort: 80, - HostName: "api.dev.example.com", - PathPrefix: "/", - EnvironmentVariables: null, - Resources: null)); - - await repository.AddAsync(app); - - // Act — Reconcile all pending environments. - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Three manifests should be applied: Deployment, Service, HTTPRoute. - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("kind: Deployment")), - It.IsAny()), Times.Once); - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("kind: Service")), - It.IsAny()), Times.Once); - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("kind: HTTPRoute")), - It.IsAny()), Times.Once); - - // The environment should be marked as Synced after successful reconciliation. - - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced); - } - - [Fact] - public async Task ReconcileAsync_DeploymentAppWithSecrets_ResolvesAndAppliesSecretManifest() - { - // Arrange — An app with a secret reference. The reconciler should - // resolve the vault key, generate a Secret manifest, and apply it. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "api", Tag: "v1", Replicas: 1, - ContainerPort: 8080, ServicePort: 80, - HostName: null, PathPrefix: null, - EnvironmentVariables: null, Resources: null)); - - env.AddSecret("DATABASE_URL", "db-connection-string"); - await repository.AddAsync(app); - - secretResolver.Setup(r => r.ResolveAsync( - tenantId, customerId, "api", envId, "db-connection-string", - It.IsAny())) - .ReturnsAsync("postgresql://db:5432/mydb"); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — A Secret manifest should be applied alongside the Deployment. - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("kind: Secret")), - It.IsAny()), Times.Once); - - // The Deployment manifest should include secretKeyRef for the secret. - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("secretKeyRef:")), - It.IsAny()), Times.Once); - } - - // ─── HelmChart-type app reconciliation ─────────────────────────────── - - [Fact] - public async Task ReconcileAsync_HelmChartApp_CallsHelmUpgradeInstall() - { - // Arrange — A HelmChart-type app. The reconciler should call - // HelmUpgradeInstallAsync instead of applying YAML manifests. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - App app = App.Create(tenantId, customerId, "Redis", "redis", AppType.HelmChart); - Guid envId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, clusterId, "redis-dev"); - - HelmReleaseSpec helmSpec = new( - RepoUrl: "https://charts.bitnami.com/bitnami", - ChartName: "redis", - ChartVersion: "18.6.1", - ValuesYaml: "replica:\n replicaCount: 3"); - - env.ConfigureHelmRelease(helmSpec); - await repository.AddAsync(app); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Should call helm upgrade install, not apply manifests. - - applyClient.Verify(c => c.HelmUpgradeInstallAsync( - clusterId, - "redis", - "redis-dev", - It.Is(s => s.ChartName == "redis" && s.ChartVersion == "18.6.1"), - It.IsAny()), Times.Once); - - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced); - } - - // ─── Error handling ────────────────────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_WhenApplyFails_MarksEnvironmentAsError() - { - // Arrange — The K8s apply call throws an exception. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "api", Tag: "v1", Replicas: 1, - ContainerPort: 8080, ServicePort: 80, - HostName: null, PathPrefix: null, - EnvironmentVariables: null, Resources: null)); - - await repository.AddAsync(app); - - applyClient.Setup(c => c.ApplyManifestAsync( - clusterId, It.IsAny(), It.IsAny())) - .ThrowsAsync(new HttpRequestException("Cluster unreachable")); - - // Act — Reconcile should not throw — it catches and marks error. - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — The environment should be marked with an error. - - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Error); - updated.Environments[0].SyncStatusMessage.Should().Contain("Cluster unreachable"); - } - - // ─── Skipping already-synced environments ──────────────────────────── - - [Fact] - public async Task ReconcileAsync_AlreadySynced_SkipsReconciliation() - { - // Arrange — An environment that's already synced shouldn't be reprocessed. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "api", Tag: "v1", Replicas: 1, - ContainerPort: 8080, ServicePort: 80, - HostName: null, PathPrefix: null, - EnvironmentVariables: null, Resources: null)); - - env.MarkSynced(); - await repository.AddAsync(app); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — No K8s calls should be made. - - applyClient.Verify(c => c.ApplyManifestAsync( - It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - - applyClient.Verify(c => c.HelmUpgradeInstallAsync( - It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); - } - - // ─── Suspended apps ────────────────────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_SuspendedApp_SkipsReconciliation() - { - // Arrange — A suspended app should not be reconciled even if - // environments are pending. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "api", Tag: "v1", Replicas: 1, - ContainerPort: 8080, ServicePort: 80, - HostName: null, PathPrefix: null, - EnvironmentVariables: null, Resources: null)); - - app.Suspend(); - await repository.AddAsync(app); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert - - applyClient.Verify(c => c.ApplyManifestAsync( - It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - // ─── Gateway auto-resolution ───────────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_RouteWithoutGateway_InjectsClusterDefaultGateway() - { - // Arrange — An app with a route that has no GatewayRef specified. - // The reconciler should ask the cluster for its default gateway and - // inject it into the route manifest automatically. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - App app = App.Create(tenantId, customerId, "Web", "web", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "web-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "registry.example.com/web", - Tag: "v1.0.0", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Services: new List - { - new("web-http", 80, 8080, "TCP", "ClusterIP") - }, - Routes: new List - { - new("web-route", "HTTP", - Hostnames: new List { "web.dev.example.com" }, - GatewayRef: null, - Rules: new List - { - new(Matches: new List { new("PathPrefix", "/", null) }, - BackendRefs: new List { new("web-http", 80, null) }, - Filters: null) - }) - })); - - await repository.AddAsync(app); - - // The cluster's ingress is Istio with an internal gateway. - - gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress")); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — The route manifest should contain the auto-resolved gateway - // parentRef even though the user never specified one. - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("name: internal") && y.Contains("namespace: internal-ingress")), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ReconcileAsync_RouteWithExplicitGateway_DoesNotOverride() - { - // Arrange — An app with a route that already has an explicit GatewayRef. - // The reconciler should NOT replace it with the cluster's default. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment); - AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "registry.example.com/api", - Tag: "v1.0.0", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Services: new List - { - new("api-http", 80, 8080, "TCP", "ClusterIP") - }, - Routes: new List - { - new("api-route", "HTTP", - Hostnames: new List { "api.dev.example.com" }, - GatewayRef: new GatewayReference("external", "external-ingress"), - Rules: new List - { - new(Matches: new List { new("PathPrefix", "/", null) }, - BackendRefs: new List { new("api-http", 80, null) }, - Filters: null) - }) - })); - - await repository.AddAsync(app); - - // The cluster has a default internal gateway, but the route specifies external. - - gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress")); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — The route should use the explicit "external" gateway, not "internal". - - applyClient.Verify(c => c.ApplyManifestAsync( - clusterId, - It.Is(y => y.Contains("name: external") && y.Contains("namespace: external-ingress")), - It.IsAny()), Times.Once); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/ConfigureAppEnvironmentHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/ConfigureAppEnvironmentHandlerTests.cs deleted file mode 100644 index 851d52f..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/ConfigureAppEnvironmentHandlerTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class ConfigureAppEnvironmentHandlerTests -{ - private readonly ConfigureAppEnvironmentHandler handler; - private readonly InMemoryAppRepository repository; - - public ConfigureAppEnvironmentHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new ConfigureAppEnvironmentHandler(repository); - } - - [Fact] - public async Task HandleAsync_DeploymentSpec_ConfiguresEnvironment() - { - // Arrange — An app with an environment that needs deployment config. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid environmentId = Guid.NewGuid(); - app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev"); - await repository.AddAsync(app); - - ConfigureDeploymentRequest request = new( - AppId: app.Id, - EnvironmentId: environmentId, - Spec: new DeploymentSpec( - Image: "registry.example.com/api", - Tag: "v1.0.0", - Replicas: 2, - ContainerPort: 8080, - ServicePort: 80, - HostName: "api.dev.example.com", - PathPrefix: "/", - EnvironmentVariables: new Dictionary { ["ENV"] = "dev" }, - Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi"))); - - // Act - - Result result = await handler.HandleDeploymentAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - App? updated = await repository.GetByIdAsync(app.Id); - AppEnvironment env = updated!.Environments[0]; - env.DeploymentSpec.Should().NotBeNull(); - env.DeploymentSpec!.Image.Should().Be("registry.example.com/api"); - env.SyncStatus.Should().Be(AppSyncStatus.Pending); - } - - [Fact] - public async Task HandleAsync_HelmSpec_ConfiguresEnvironment() - { - // Arrange — A Helm-type app with an environment that needs chart config. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart); - Guid environmentId = Guid.NewGuid(); - app.AddEnvironment(environmentId, Guid.NewGuid(), "redis-dev"); - await repository.AddAsync(app); - - ConfigureHelmReleaseRequest request = new( - AppId: app.Id, - EnvironmentId: environmentId, - Spec: new HelmReleaseSpec( - RepoUrl: "https://charts.bitnami.com/bitnami", - ChartName: "redis", - ChartVersion: "18.6.1", - ValuesYaml: "replica:\n replicaCount: 3")); - - // Act - - Result result = await handler.HandleHelmReleaseAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - App? updated = await repository.GetByIdAsync(app.Id); - AppEnvironment env = updated!.Environments[0]; - env.HelmReleaseSpec.Should().NotBeNull(); - env.HelmReleaseSpec!.ChartName.Should().Be("redis"); - } - - [Fact] - public async Task HandleAsync_NonExistentApp_ReturnsFailure() - { - // Arrange & Act - - ConfigureDeploymentRequest request = new(Guid.NewGuid(), Guid.NewGuid(), - new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null)); - - Result result = await handler.HandleDeploymentAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_NonExistentEnvironment_ReturnsFailure() - { - // Arrange — App exists but the environment ID doesn't match. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - await repository.AddAsync(app); - - ConfigureDeploymentRequest request = new(app.Id, Guid.NewGuid(), - new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null)); - - // Act - - Result result = await handler.HandleDeploymentAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/CreateAppHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/CreateAppHandlerTests.cs deleted file mode 100644 index 14481c5..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/CreateAppHandlerTests.cs +++ /dev/null @@ -1,107 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.CreateApp; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class CreateAppHandlerTests -{ - private readonly CreateAppHandler handler; - private readonly InMemoryAppRepository repository; - - public CreateAppHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new CreateAppHandler(repository); - } - - [Fact] - public async Task HandleAsync_WithValidRequest_CreatesAppAndReturnsId() - { - // Arrange — A customer admin creates a new Deployment-type app. - - CreateAppRequest request = new( - TenantId: Guid.NewGuid(), - CustomerId: Guid.NewGuid(), - Name: "Frontend Portal", - Slug: "frontend-portal", - Type: AppType.Deployment); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — The app is created and its ID returned. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - App? stored = await repository.GetByIdAsync(result.Value!); - stored.Should().NotBeNull(); - stored!.Name.Should().Be("Frontend Portal"); - stored.Type.Should().Be(AppType.Deployment); - } - - [Fact] - public async Task HandleAsync_WithDuplicateSlug_ReturnsFailure() - { - // Arrange — An app with the same slug already exists for this customer. - - Guid customerId = Guid.NewGuid(); - - CreateAppRequest first = new(Guid.NewGuid(), customerId, "App One", "my-app", AppType.Deployment); - await handler.HandleAsync(first); - - CreateAppRequest duplicate = new(Guid.NewGuid(), customerId, "App Two", "my-app", AppType.HelmChart); - - // Act - - Result result = await handler.HandleAsync(duplicate); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("already exists"); - } - - [Fact] - public async Task HandleAsync_WithEmptyName_ReturnsFailure() - { - // Arrange & Act - - CreateAppRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "", "slug", AppType.Deployment); - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("name"); - } - - [Fact] - public async Task HandleAsync_WithHelmChartType_CreatesHelmApp() - { - // Arrange — A customer wants to deploy a Helm chart-based app. - - CreateAppRequest request = new( - TenantId: Guid.NewGuid(), - CustomerId: Guid.NewGuid(), - Name: "Redis Cache", - Slug: "redis-cache", - Type: AppType.HelmChart); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - App? stored = await repository.GetByIdAsync(result.Value!); - stored.Should().NotBeNull(); - stored!.Type.Should().Be(AppType.HelmChart); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/DatabaseStatusReconcileHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/DatabaseStatusReconcileHandlerTests.cs deleted file mode 100644 index b30a853..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/DatabaseStatusReconcileHandlerTests.cs +++ /dev/null @@ -1,425 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.DatabaseStatusReconcile; -using EntKube.Provisioning.Features.MongoClusters; -using EntKube.Provisioning.Features.PostgresClusters; -using EntKube.Provisioning.Features.RedisClusters; -using EntKube.Provisioning.Infrastructure; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for the DatabaseStatusReconcileHandler, which periodically checks -/// Kubernetes CRD status for CNPG, Redis, and MongoDB clusters and transitions -/// the domain models from Provisioning → Running (or Degraded) based on live state. -/// -/// This is the missing piece that caused database clusters to stay stuck in -/// "Provisioning" status forever even after Kubernetes reported them as healthy. -/// -public class DatabaseStatusReconcileHandlerTests -{ - private readonly InMemoryPostgresClusterRepository pgRepository; - private readonly InMemoryRedisClusterRepository redisRepository; - private readonly InMemoryMongoClusterRepository mongoRepository; - private readonly Mock cnpgClient; - private readonly Mock redisClient; - private readonly Mock mongoClient; - private readonly Mock credentialsResolver; - private readonly DatabaseStatusReconcileHandler handler; - - private const string FakeKubeConfig = "apiVersion: v1\nclusters: []"; - private const string FakeContextName = "test-context"; - - public DatabaseStatusReconcileHandlerTests() - { - pgRepository = new InMemoryPostgresClusterRepository(); - redisRepository = new InMemoryRedisClusterRepository(); - mongoRepository = new InMemoryMongoClusterRepository(); - cnpgClient = new Mock(); - redisClient = new Mock(); - mongoClient = new Mock(); - credentialsResolver = new Mock(); - - handler = new DatabaseStatusReconcileHandler( - pgRepository, - redisRepository, - mongoRepository, - cnpgClient.Object, - redisClient.Object, - mongoClient.Object, - credentialsResolver.Object); - } - - // ─── CNPG PostgreSQL ───────────────────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_CnpgClusterHealthy_TransitionsToRunning() - { - // Arrange — A freshly provisioned CNPG cluster still in Provisioning state. - // Kubernetes reports it as healthy (phase = "Cluster in healthy state"). - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi"); - - await pgRepository.AddAsync(pgCluster); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - cnpgClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny())) - .ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "test-db-1", null, null)); - - // Act — The reconciler checks the status and transitions accordingly. - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — The cluster should now be Running. - - PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Running); - updated.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public async Task ReconcileAsync_CnpgClusterNotReady_StaysProvisioning() - { - // Arrange — A CNPG cluster where K8s reports it's still setting up - // (not all instances are ready yet). - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi"); - - await pgRepository.AddAsync(pgCluster); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - cnpgClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny())) - .ReturnsAsync(new CnpgClusterStatus("Setting up primary", 0, 3, null, null, null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Still provisioning; not all instances are ready. - - PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Provisioning); - } - - [Fact] - public async Task ReconcileAsync_CnpgClusterAlreadyRunning_SkipsButChecksForDegradation() - { - // Arrange — A cluster that's already Running. The reconciler should check - // its health and mark it Degraded if it has become unhealthy. - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), clusterId, "healthy-db", "cnpg-system", "16.4", 3, "50Gi"); - pgCluster.MarkRunning(); - - await pgRepository.AddAsync(pgCluster); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - cnpgClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "healthy-db", "cnpg-system", It.IsAny())) - .ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 2, 3, "healthy-db-1", null, null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Not all instances ready → mark degraded. - - PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Degraded); - } - - [Fact] - public async Task ReconcileAsync_CnpgUpgrading_CompletesUpgradeWhenHealthy() - { - // Arrange — A cluster that's mid-upgrade. When K8s reports healthy at the - // target version, the reconciler should complete the upgrade. - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), clusterId, "upgrade-db", "cnpg-system", "15.6", 3, "50Gi"); - pgCluster.MarkRunning(); - pgCluster.RequestUpgrade("16.4"); - - await pgRepository.AddAsync(pgCluster); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - cnpgClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "upgrade-db", "cnpg-system", It.IsAny())) - .ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "upgrade-db-1", null, null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Upgrade completed, back to Running. - - PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Running); - updated.PostgresVersion.Should().Be("16.4"); - } - - // ─── Redis ─────────────────────────────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_RedisClusterReady_TransitionsToRunning() - { - // Arrange — A freshly provisioned Redis cluster still in Provisioning state. - // Kubernetes reports all replicas are ready. - - Guid clusterId = Guid.NewGuid(); - - RedisCluster redis = RedisCluster.Create( - Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true); - - await redisRepository.AddAsync(redis); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - redisClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny())) - .ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "test-redis-0")); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — The Redis cluster should now be Running. - - RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id); - updated!.Status.Should().Be(RedisClusterStatus.Running); - updated.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public async Task ReconcileAsync_RedisClusterNotReady_StaysProvisioning() - { - // Arrange — Redis operator reports Unknown phase with no ready replicas. - - Guid clusterId = Guid.NewGuid(); - - RedisCluster redis = RedisCluster.Create( - Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true); - - await redisRepository.AddAsync(redis); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - redisClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny())) - .ReturnsAsync(new RedisCrdStatus("Unknown", 0, 0, null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Still provisioning. - - RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id); - updated!.Status.Should().Be(RedisClusterStatus.Provisioning); - } - - [Fact] - public async Task ReconcileAsync_RedisUpgrading_CompletesUpgradeWhenReady() - { - // Arrange — Redis cluster mid-upgrade. - - Guid clusterId = Guid.NewGuid(); - - RedisCluster redis = RedisCluster.Create( - Guid.NewGuid(), clusterId, "upgrade-redis", "redis", "7.0.15", 3, "10Gi", true); - redis.MarkRunning(); - redis.RequestUpgrade("7.2.5"); - - await redisRepository.AddAsync(redis); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - redisClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "upgrade-redis", "redis", It.IsAny())) - .ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "upgrade-redis-0")); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Upgrade completed. - - RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id); - updated!.Status.Should().Be(RedisClusterStatus.Running); - updated.RedisVersion.Should().Be("7.2.5"); - } - - // ─── MongoDB ───────────────────────────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_MongoClusterRunningPhase_TransitionsToRunning() - { - // Arrange — A freshly provisioned MongoDB cluster still in Provisioning. - // The operator reports phase = "Running" with all members ready. - - Guid clusterId = Guid.NewGuid(); - - MongoCluster mongo = MongoCluster.Create( - Guid.NewGuid(), clusterId, "test-mongo", "mongodb-system", "7.0.12", 3, "50Gi"); - - await mongoRepository.AddAsync(mongo); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - mongoClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "test-mongo", "mongodb-system", It.IsAny())) - .ReturnsAsync(new MongoCrdStatus("Running", 3, 3, "test-mongo-0", null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — The MongoDB cluster should now be Running. - - MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id); - updated!.Status.Should().Be(MongoClusterStatus.Running); - updated.LastReconcileAt.Should().NotBeNull(); - } - - [Fact] - public async Task ReconcileAsync_MongoClusterFailed_MarksDegraded() - { - // Arrange — A MongoDB cluster where the operator reports phase = "Failed". - // This matches the user's scenario where MongoDB shows "Failed" with 0/3 ready. - - Guid clusterId = Guid.NewGuid(); - - MongoCluster mongo = MongoCluster.Create( - Guid.NewGuid(), clusterId, "failed-mongo", "mongodb-system", "7.0.12", 3, "50Gi"); - - await mongoRepository.AddAsync(mongo); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - mongoClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "failed-mongo", "mongodb-system", It.IsAny())) - .ReturnsAsync(new MongoCrdStatus("Failed", 0, 3, null, null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Failed phase → Degraded with message. - - MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id); - updated!.Status.Should().Be(MongoClusterStatus.Degraded); - updated.StatusMessage.Should().Contain("Failed"); - } - - [Fact] - public async Task ReconcileAsync_MongoClusterNotReady_StaysProvisioning() - { - // Arrange — MongoDB with pending phase and 0 ready members. - - Guid clusterId = Guid.NewGuid(); - - MongoCluster mongo = MongoCluster.Create( - Guid.NewGuid(), clusterId, "pending-mongo", "mongodb-system", "7.0.12", 3, "50Gi"); - - await mongoRepository.AddAsync(mongo); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - mongoClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "pending-mongo", "mongodb-system", It.IsAny())) - .ReturnsAsync(new MongoCrdStatus("Pending", 0, 3, null, null)); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Still provisioning. - - MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id); - updated!.Status.Should().Be(MongoClusterStatus.Provisioning); - } - - // ─── Credential resolution failures ────────────────────────────────── - - [Fact] - public async Task ReconcileAsync_CredentialResolutionFails_SkipsCluster() - { - // Arrange — Can't reach the Clusters service. The reconciler should skip - // this cluster without crashing and move on to others. - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), clusterId, "unreachable-db", "cnpg-system", "16.4", 3, "50Gi"); - - await pgRepository.AddAsync(pgCluster); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync((ClusterCredentials?)null); - - // Act — Should not throw. - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Status unchanged. - - PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Provisioning); - } - - [Fact] - public async Task ReconcileAsync_StatusQueryReturnsNull_DoesNotChangeStatus() - { - // Arrange — The K8s status query returns null (cluster CR not found). - // The reconciler should leave the status unchanged. - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), clusterId, "missing-cr-db", "cnpg-system", "16.4", 3, "50Gi"); - - await pgRepository.AddAsync(pgCluster); - - credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny())) - .ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName)); - - cnpgClient.Setup(c => c.GetClusterStatusAsync( - FakeKubeConfig, FakeContextName, "missing-cr-db", "cnpg-system", It.IsAny())) - .ReturnsAsync((CnpgClusterStatus?)null); - - // Act - - await handler.ReconcileAsync(CancellationToken.None); - - // Assert — Status unchanged. - - PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Provisioning); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/DecommissionServiceHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/DecommissionServiceHandlerTests.cs deleted file mode 100644 index dd6e3d0..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/DecommissionServiceHandlerTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.DecommissionService; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class DecommissionServiceHandlerTests -{ - private readonly DecommissionServiceHandler handler; - private readonly InMemoryServiceInstanceRepository repository; - - public DecommissionServiceHandlerTests() - { - repository = new InMemoryServiceInstanceRepository(); - handler = new DecommissionServiceHandler(repository); - } - - [Fact] - public async Task HandleAsync_WithRunningService_SetsDesiredStateToDecommissioned() - { - // Arrange — A running service exists in the repository. - - ServiceInstance instance = ServiceInstance.Provision( - Guid.NewGuid(), - ServiceType.MinIO, - "tenant-storage", - "minio-system", - Guid.NewGuid()); - - instance.MarkRunning(); - await repository.AddAsync(instance); - - // Act — Request decommission. - - Result result = await handler.HandleAsync(instance.Id); - - // Assert — The desired state should be Decommissioned. - - result.IsSuccess.Should().BeTrue(); - - ServiceInstance? updated = await repository.GetByIdAsync(instance.Id); - updated.Should().NotBeNull(); - updated!.DesiredState.Should().Be(ServiceState.Decommissioned); - } - - [Fact] - public async Task HandleAsync_WithNonExistentId_ReturnsFailure() - { - // Arrange — An ID that doesn't exist. - - Guid unknownId = Guid.NewGuid(); - - // Act - - Result result = await handler.HandleAsync(unknownId); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/DeleteAppHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/DeleteAppHandlerTests.cs deleted file mode 100644 index b0d18b2..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/DeleteAppHandlerTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.DeleteApp; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class DeleteAppHandlerTests -{ - private readonly InMemoryAppRepository repository; - private readonly DeleteAppHandler handler; - - public DeleteAppHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new DeleteAppHandler(repository); - } - - [Fact] - public async Task HandleAsync_ExistingApp_DeletesSuccessfully() - { - // Arrange — An existing app in the repository. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync(app.Id); - - // Assert — The app should be gone from the repository. - - result.IsSuccess.Should().BeTrue(); - App? deleted = await repository.GetByIdAsync(app.Id); - deleted.Should().BeNull(); - } - - [Fact] - public async Task HandleAsync_NonexistentApp_ReturnsFailure() - { - // Act - - Result result = await handler.HandleAsync(Guid.NewGuid()); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/EfKeycloakRealmRepositoryTests.cs b/tests/EntKube.Provisioning.Tests/Features/EfKeycloakRealmRepositoryTests.cs deleted file mode 100644 index 751360d..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/EfKeycloakRealmRepositoryTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Infrastructure; -using FluentAssertions; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Integration tests for EF Core provisioning repositories. Uses a real SQLite -/// database to verify that change tracking works correctly when adding child -/// entities with pre-set Guid keys to tracked aggregates. -/// -public class EfIdentityRealmRepositoryTests : IDisposable -{ - private readonly SqliteConnection connection; - private readonly ProvisioningDbContext db; - private readonly EfIdentityRealmRepository repository; - - public EfIdentityRealmRepositoryTests() - { - connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - - DbContextOptions options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - db = new ProvisioningDbContext(options); - db.Database.EnsureCreated(); - repository = new EfIdentityRealmRepository(db); - } - - [Fact] - public async Task UpdateAsync_AddingIdentityProvider_DoesNotThrowConcurrencyException() - { - // Arrange — Create and persist a realm, then load it back. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - IdentityRealm realm = IdentityRealm.Create(environmentId, "test-realm", clusterId); - await repository.AddAsync(realm); - - IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id); - loaded.Should().NotBeNull(); - - // Act — Add a new identity provider to the tracked realm and save. - // Before the fix, Update() would mark the new provider as Modified - // instead of Added, causing DbUpdateConcurrencyException. - - RealmIdentityProvider idp = RealmIdentityProvider.Create( - "google", "Google", IdentityProviderType.Google); - - loaded!.AddIdentityProvider(idp); - - Func act = async () => await repository.UpdateAsync(loaded); - - // Assert — Should save without throwing. - - await act.Should().NotThrowAsync(); - - IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id); - reloaded!.IdentityProviders.Should().HaveCount(1); - reloaded.IdentityProviders[0].Alias.Should().Be("google"); - } - - [Fact] - public async Task UpdateAsync_AddingOrganization_DoesNotThrowConcurrencyException() - { - // Arrange — Create and persist a realm. - - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - IdentityRealm realm = IdentityRealm.Create(environmentId, "org-realm", clusterId); - await repository.AddAsync(realm); - - IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id); - loaded.Should().NotBeNull(); - - // Act — Add a new organization and save. - - RealmOrganization org = RealmOrganization.Create("Engineering", "Dev teams"); - loaded!.AddOrganization(org); - - Func act = async () => await repository.UpdateAsync(loaded); - - // Assert - - await act.Should().NotThrowAsync(); - - IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id); - reloaded!.Organizations.Should().HaveCount(1); - reloaded.Organizations[0].Name.Should().Be("Engineering"); - } - - public void Dispose() - { - db.Dispose(); - connection.Dispose(); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs b/tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs deleted file mode 100644 index e0ce380..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs +++ /dev/null @@ -1,1107 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.Reconcile; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class ManifestGeneratorTests -{ - // ─── Deployment Manifest ───────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithFullSpec_ProducesValidYaml() - { - // Arrange — A fully configured deployment spec for an API service. - // The generator should produce a Kubernetes Deployment manifest with - // the container image, port, replicas, resource limits, and env vars. - - DeploymentSpec spec = new( - Image: "registry.example.com/api", - Tag: "v1.2.3", - Replicas: 3, - ContainerPort: 8080, - ServicePort: 80, - HostName: "api.dev.example.com", - PathPrefix: "/api", - EnvironmentVariables: new Dictionary - { - ["ASPNETCORE_ENVIRONMENT"] = "Development", - ["LOG_LEVEL"] = "Debug" - }, - Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi")); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("my-api", "api-dev", spec); - - // Assert — The YAML should contain the key Kubernetes Deployment fields. - - yaml.Should().Contain("kind: Deployment"); - yaml.Should().Contain("name: my-api"); - yaml.Should().Contain("namespace: api-dev"); - yaml.Should().Contain("replicas: 3"); - yaml.Should().Contain("registry.example.com/api:v1.2.3"); - yaml.Should().Contain("containerPort: 8080"); - yaml.Should().Contain("cpu: \"100m\""); - yaml.Should().Contain("cpu: \"500m\""); - yaml.Should().Contain("memory: \"128Mi\""); - yaml.Should().Contain("memory: \"512Mi\""); - yaml.Should().Contain("ASPNETCORE_ENVIRONMENT"); - yaml.Should().Contain("Development"); - } - - [Fact] - public void GenerateDeployment_WithoutResources_OmitsResourceBlock() - { - // Arrange — A minimal deployment without resource constraints. - - DeploymentSpec spec = new( - Image: "nginx", - Tag: "latest", - Replicas: 1, - ContainerPort: 80, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("web", "default", spec); - - // Assert - - yaml.Should().Contain("kind: Deployment"); - yaml.Should().Contain("nginx:latest"); - yaml.Should().NotContain("resources:"); - } - - // ─── Service Manifest ──────────────────────────────────────────────── - - [Fact] - public void GenerateService_WithSpec_ProducesClusterIPService() - { - // Arrange — The generator creates a ClusterIP Service that maps - // the service port to the container port, matching pod labels. - - DeploymentSpec spec = new( - Image: "registry.example.com/api", - Tag: "v1.0.0", - Replicas: 2, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null); - - // Act - - string yaml = ManifestGenerator.GenerateService("my-api", "api-dev", spec); - - // Assert - - yaml.Should().Contain("kind: Service"); - yaml.Should().Contain("name: my-api"); - yaml.Should().Contain("namespace: api-dev"); - yaml.Should().Contain("port: 80"); - yaml.Should().Contain("targetPort: 8080"); - yaml.Should().Contain("app: my-api"); - } - - // ─── HTTPRoute Manifest ────────────────────────────────────────────── - - [Fact] - public void GenerateHttpRoute_WithHostAndPath_ProducesRouteYaml() - { - // Arrange — When a hostname and path prefix are provided, - // we generate a Gateway API HTTPRoute resource. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: "api.dev.example.com", - PathPrefix: "/api", - EnvironmentVariables: null, - Resources: null); - - // Act - - string yaml = ManifestGenerator.GenerateHttpRoute("my-api", "api-dev", spec); - - // Assert - - yaml.Should().Contain("kind: HTTPRoute"); - yaml.Should().Contain("name: my-api"); - yaml.Should().Contain("namespace: api-dev"); - yaml.Should().Contain("api.dev.example.com"); - yaml.Should().Contain("/api"); - yaml.Should().Contain("port: 80"); - } - - [Fact] - public void GenerateHttpRoute_WithoutHost_ReturnsEmpty() - { - // Arrange — No hostname means no ingress routing needed. - - DeploymentSpec spec = new( - Image: "worker", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null); - - // Act - - string yaml = ManifestGenerator.GenerateHttpRoute("worker", "default", spec); - - // Assert - - yaml.Should().BeEmpty(); - } - - // ─── Secrets Manifest ──────────────────────────────────────────────── - - [Fact] - public void GenerateSecret_WithEntries_ProducesOpaqueSecret() - { - // Arrange — Secret values resolved from the vault become a - // Kubernetes Opaque Secret with base64-encoded data. - - Dictionary resolvedSecrets = new() - { - ["DATABASE_URL"] = "postgresql://db:5432/mydb", - ["API_KEY"] = "sk-abc123" - }; - - // Act - - string yaml = ManifestGenerator.GenerateSecret("my-api", "api-dev", resolvedSecrets); - - // Assert - - yaml.Should().Contain("kind: Secret"); - yaml.Should().Contain("name: my-api"); - yaml.Should().Contain("namespace: api-dev"); - yaml.Should().Contain("type: Opaque"); - yaml.Should().Contain("DATABASE_URL:"); - yaml.Should().Contain("API_KEY:"); - } - - [Fact] - public void GenerateSecret_Empty_ReturnsEmpty() - { - // Arrange — No secrets means no Secret resource needed. - - Dictionary empty = new(); - - // Act - - string yaml = ManifestGenerator.GenerateSecret("my-api", "default", empty); - - // Assert - - yaml.Should().BeEmpty(); - } - - // ─── Environment Variable Injection ────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithSecrets_InjectsSecretKeyRefs() - { - // Arrange — When an app has secrets, the deployment should reference - // them via secretKeyRef so the container gets environment variables - // populated from the Kubernetes Secret resource. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null); - - List secrets = new() - { - new AppSecret("DATABASE_URL", "db-conn-string"), - new AppSecret("API_KEY", "api-key") - }; - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("my-api", "api-dev", spec, secrets); - - // Assert — Should have secretKeyRef entries for each secret. - - yaml.Should().Contain("secretKeyRef:"); - yaml.Should().Contain("name: DATABASE_URL"); - yaml.Should().Contain("key: DATABASE_URL"); - yaml.Should().Contain("name: API_KEY"); - } - - // ─── Volume Mounts ────────────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithVolumes_ProducesVolumeMountAndVolumeBlocks() - { - // Arrange — An app that needs persistent storage mounts a PVC - // into the container at a specific path. The manifest should include - // both a volumeMount on the container and a volume on the pod spec. - - DeploymentSpec spec = new( - Image: "postgres", - Tag: "16", - Replicas: 1, - ContainerPort: 5432, - ServicePort: 5432, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: new List - { - new("data", new VolumePvcSource("postgres-data-pvc"), MountPath: "/var/lib/postgresql/data"), - new("config", new VolumeConfigMapSource("postgres-config"), MountPath: "/etc/postgresql") - }, - Containers: null, - SecurityContext: null, - ServiceAccountName: null, - Annotations: null, - Labels: null, - Probes: null, - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("db", "data-ns", spec); - - // Assert — The YAML should have volume mounts on the container - // and volume definitions on the pod spec. - - yaml.Should().Contain("volumeMounts:"); - yaml.Should().Contain("name: data"); - yaml.Should().Contain("mountPath: \"/var/lib/postgresql/data\""); - yaml.Should().Contain("persistentVolumeClaim:"); - yaml.Should().Contain("claimName: postgres-data-pvc"); - yaml.Should().Contain("configMap:"); - yaml.Should().Contain("name: postgres-config"); - } - - // ─── Security Context ─────────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithSecurityContext_ProducesSecurityBlocks() - { - // Arrange — For hardened workloads, the deployment needs pod-level - // and container-level security context settings: non-root user, - // read-only root filesystem, dropped capabilities. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: null, - Containers: null, - SecurityContext: new PodSecurityContextSpec( - RunAsUser: 1000, - RunAsGroup: 1000, - FsGroup: 2000, - RunAsNonRoot: true), - ServiceAccountName: null, - Annotations: null, - Labels: null, - Probes: null, - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("secure-api", "prod", spec); - - // Assert - - yaml.Should().Contain("securityContext:"); - yaml.Should().Contain("runAsUser: 1000"); - yaml.Should().Contain("runAsGroup: 1000"); - yaml.Should().Contain("fsGroup: 2000"); - yaml.Should().Contain("runAsNonRoot: true"); - } - - // ─── Multiple Containers (Init + Sidecar) ─────────────────────────── - - [Fact] - public void GenerateDeployment_WithAdditionalContainers_ProducesMultiContainerPod() - { - // Arrange — Some workloads need init containers (e.g. DB migrations) - // and sidecar containers (e.g. proxy, log shipper). The spec supports - // adding extra containers beyond the primary one. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v2", - Replicas: 2, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: null, - Containers: new AdditionalContainersSpec( - InitContainers: new List - { - new("migrate", "api", "v2", 0, new[] { "dotnet", "ef", "database", "update" }.ToList(), - null, null, null, null, null) - }, - Sidecars: new List - { - new("log-shipper", "fluent/fluent-bit", "latest", 0, null, - null, null, null, null, null) - }), - SecurityContext: null, - ServiceAccountName: null, - Annotations: null, - Labels: null, - Probes: null, - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("my-api", "dev", spec); - - // Assert — Should have initContainers and sidecar containers. - - yaml.Should().Contain("initContainers:"); - yaml.Should().Contain("name: migrate"); - yaml.Should().Contain("command:"); - yaml.Should().Contain("name: log-shipper"); - yaml.Should().Contain("fluent/fluent-bit:latest"); - } - - // ─── Probes ───────────────────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithProbes_ProducesProbeBlocks() - { - // Arrange — Kubernetes uses probes to determine container health. - // A liveness probe restarts unhealthy containers; a readiness probe - // removes them from service endpoints until they're ready. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: null, - Containers: null, - SecurityContext: null, - ServiceAccountName: null, - Annotations: null, - Labels: null, - Probes: new ProbesSpec( - Liveness: new ProbeSpec("HTTP", "/healthz", 8080, 15, 20, 3), - Readiness: new ProbeSpec("HTTP", "/ready", 8080, 5, 10, 1), - Startup: new ProbeSpec("HTTP", "/healthz", 8080, 0, 5, 30)), - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec); - - // Assert - - yaml.Should().Contain("livenessProbe:"); - yaml.Should().Contain("path: /healthz"); - yaml.Should().Contain("readinessProbe:"); - yaml.Should().Contain("path: /ready"); - yaml.Should().Contain("startupProbe:"); - } - - // ─── ConfigMaps ───────────────────────────────────────────────────── - - [Fact] - public void GenerateConfigMap_WithEntries_ProducesConfigMapYaml() - { - // Arrange — Apps may need ConfigMap resources for configuration files - // or simple key-value settings. The generator creates a ConfigMap - // manifest from structured data. - - ConfigMapSpec configMap = new( - "app-config", - new Dictionary - { - ["appsettings.json"] = "{\"Logging\":{\"LogLevel\":{\"Default\":\"Information\"}}}", - ["feature-flags.json"] = "{\"enableBeta\":true}" - }); - - // Act - - string yaml = ManifestGenerator.GenerateConfigMap("my-api", "dev", configMap); - - // Assert - - yaml.Should().Contain("kind: ConfigMap"); - yaml.Should().Contain("name: app-config"); - yaml.Should().Contain("namespace: dev"); - yaml.Should().Contain("appsettings.json:"); - yaml.Should().Contain("feature-flags.json:"); - } - - // ─── Service Account ──────────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithServiceAccount_IncludesServiceAccountName() - { - // Arrange — When a workload needs to access the Kubernetes API - // or cloud resources, it runs under a specific service account. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: null, - Containers: null, - SecurityContext: null, - ServiceAccountName: "api-service-account", - Annotations: null, - Labels: null, - Probes: null, - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec); - - // Assert - - yaml.Should().Contain("serviceAccountName: api-service-account"); - } - - // ─── Annotations & Labels ─────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithAnnotationsAndLabels_IncludesMetadata() - { - // Arrange — Teams need custom annotations (e.g., Prometheus scrape config, - // Istio injection) and labels (e.g., team ownership, cost center). - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: null, - Containers: null, - SecurityContext: null, - ServiceAccountName: null, - Annotations: new Dictionary - { - ["prometheus.io/scrape"] = "true", - ["prometheus.io/port"] = "8080" - }, - Labels: new Dictionary - { - ["team"] = "platform", - ["cost-center"] = "engineering" - }, - Probes: null, - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec); - - // Assert - - yaml.Should().Contain("prometheus.io/scrape: \"true\""); - yaml.Should().Contain("prometheus.io/port: \"8080\""); - yaml.Should().Contain("team: platform"); - yaml.Should().Contain("cost-center: engineering"); - } - - // ─── Backward Compatibility ───────────────────────────────────────── - - [Fact] - public void GenerateDeployment_MinimalSpecWithNewNullFields_StillWorks() - { - // Arrange — The existing minimal spec (with all new fields null/empty) - // should still produce the same output as before, ensuring backward - // compatibility for existing apps. - - DeploymentSpec spec = new( - Image: "nginx", - Tag: "latest", - Replicas: 1, - ContainerPort: 80, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - Volumes: null, - Containers: null, - SecurityContext: null, - ServiceAccountName: null, - Annotations: null, - Labels: null, - Probes: null, - ConfigMaps: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("web", "default", spec); - - // Assert — Should produce a basic deployment without any of the - // new blocks (no volumes, no security context, no probes, etc.) - - yaml.Should().Contain("kind: Deployment"); - yaml.Should().Contain("nginx:latest"); - yaml.Should().NotContain("volumeMounts:"); - yaml.Should().NotContain("securityContext:"); - yaml.Should().NotContain("initContainers:"); - yaml.Should().NotContain("livenessProbe:"); - yaml.Should().NotContain("serviceAccountName:"); - } - - // ─── Multiple Services ────────────────────────────────────────────── - - [Fact] - public void GenerateServices_WithMultipleSpecs_ProducesOneServicePerSpec() - { - // Arrange — An app might expose both an HTTP API on port 80 and a gRPC - // endpoint on port 9090, each needing its own Kubernetes Service. - // The services list replaces the old single ServicePort/ContainerPort pair. - - List services = new() - { - new ServiceSpec("my-api-http", 80, 8080, "TCP", "ClusterIP"), - new ServiceSpec("my-api-grpc", 9090, 9090, "TCP", "ClusterIP") - }; - - // Act - - List yamls = services.Select(s => - ManifestGenerator.GenerateService("my-api", "api-dev", s)).ToList(); - - // Assert — Each service gets its own YAML with the correct ports. - - yamls.Should().HaveCount(2); - yamls[0].Should().Contain("name: my-api-http"); - yamls[0].Should().Contain("port: 80"); - yamls[0].Should().Contain("targetPort: 8080"); - yamls[1].Should().Contain("name: my-api-grpc"); - yamls[1].Should().Contain("port: 9090"); - } - - [Fact] - public void GenerateService_WithUdpProtocol_SetsProtocolField() - { - // Arrange — A DNS or game server might need a UDP service. - - ServiceSpec svc = new("dns-server", 53, 5353, "UDP", "ClusterIP"); - - // Act - - string yaml = ManifestGenerator.GenerateService("dns", "infra", svc); - - // Assert - - yaml.Should().Contain("protocol: UDP"); - yaml.Should().Contain("port: 53"); - yaml.Should().Contain("targetPort: 5353"); - } - - [Fact] - public void GenerateService_WithNodePortType_SetsServiceType() - { - // Arrange — Some services need NodePort or LoadBalancer exposure. - - ServiceSpec svc = new("web-public", 80, 8080, "TCP", "NodePort"); - - // Act - - string yaml = ManifestGenerator.GenerateService("web", "prod", svc); - - // Assert - - yaml.Should().Contain("type: NodePort"); - } - - // ─── Gateway API Routes ───────────────────────────────────────────── - - [Fact] - public void GenerateRoute_HttpRoute_WithMatchesAndFilters() - { - // Arrange — An HTTPRoute with multiple path matches, header matching, - // and a gateway reference. This is the full-featured version of the - // old GenerateHttpRoute that only supported hostname + path prefix. - - RouteSpec route = new( - Name: "api-route", - Type: "HTTP", - Hostnames: new List { "api.example.com", "api.internal.example.com" }, - GatewayRef: new GatewayReference("main-gateway", "gateway-ns"), - Rules: new List - { - new( - Matches: new List - { - new("PathPrefix", "/api/v1", null), - new("PathPrefix", "/api/v2", null) - }, - BackendRefs: new List - { - new("my-api-http", 80, null) - }, - Filters: null) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("my-api", "api-dev", route); - - // Assert - - yaml.Should().Contain("kind: HTTPRoute"); - yaml.Should().Contain("api.example.com"); - yaml.Should().Contain("api.internal.example.com"); - yaml.Should().Contain("name: main-gateway"); - yaml.Should().Contain("namespace: gateway-ns"); - yaml.Should().Contain("/api/v1"); - yaml.Should().Contain("/api/v2"); - yaml.Should().Contain("name: my-api-http"); - yaml.Should().Contain("port: 80"); - } - - [Fact] - public void GenerateRoute_TlsRoute_ProducesTLSRouteManifest() - { - // Arrange — A TLSRoute performs TLS passthrough routing. The Gateway - // terminates at the SNI hostname level and passes the encrypted - // connection directly to the backend — useful for services that - // handle their own TLS (databases, MQTT brokers, etc.) - - RouteSpec route = new( - Name: "db-tls-route", - Type: "TLS", - Hostnames: new List { "db.secure.example.com" }, - GatewayRef: new GatewayReference("tls-gateway", "gateway-ns"), - Rules: new List - { - new( - Matches: null, - BackendRefs: new List - { - new("db-service", 5432, null) - }, - Filters: null) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("db-app", "data-ns", route); - - // Assert - - yaml.Should().Contain("kind: TLSRoute"); - yaml.Should().Contain("apiVersion: gateway.networking.k8s.io/v1alpha2"); - yaml.Should().Contain("db.secure.example.com"); - yaml.Should().Contain("name: db-service"); - yaml.Should().Contain("port: 5432"); - } - - [Fact] - public void GenerateRoute_TcpRoute_ProducesTCPRouteManifest() - { - // Arrange — A TCPRoute routes raw TCP traffic. No hostname matching — - // it's port-based. Used for databases, Redis, custom TCP protocols. - - RouteSpec route = new( - Name: "redis-route", - Type: "TCP", - Hostnames: null, - GatewayRef: new GatewayReference("tcp-gateway", "gateway-ns"), - Rules: new List - { - new( - Matches: null, - BackendRefs: new List - { - new("redis-svc", 6379, null) - }, - Filters: null) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("redis", "cache-ns", route); - - // Assert - - yaml.Should().Contain("kind: TCPRoute"); - yaml.Should().Contain("apiVersion: gateway.networking.k8s.io/v1alpha2"); - yaml.Should().Contain("name: redis-svc"); - yaml.Should().Contain("port: 6379"); - yaml.Should().NotContain("hostnames:"); - } - - [Fact] - public void GenerateRoute_UdpRoute_ProducesUDPRouteManifest() - { - // Arrange — A UDPRoute for services that use UDP: DNS servers, - // game servers, VoIP, etc. - - RouteSpec route = new( - Name: "dns-route", - Type: "UDP", - Hostnames: null, - GatewayRef: new GatewayReference("udp-gateway", "gateway-ns"), - Rules: new List - { - new( - Matches: null, - BackendRefs: new List - { - new("dns-svc", 53, null) - }, - Filters: null) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("dns", "infra", route); - - // Assert - - yaml.Should().Contain("kind: UDPRoute"); - yaml.Should().Contain("apiVersion: gateway.networking.k8s.io/v1alpha2"); - yaml.Should().Contain("name: dns-svc"); - yaml.Should().Contain("port: 53"); - } - - [Fact] - public void GenerateRoute_GrpcRoute_ProducesGRPCRouteManifest() - { - // Arrange — A GRPCRoute for gRPC services. Uses method matching - // instead of path matching. - - RouteSpec route = new( - Name: "grpc-route", - Type: "GRPC", - Hostnames: new List { "grpc.example.com" }, - GatewayRef: new GatewayReference("main-gateway", "gateway-ns"), - Rules: new List - { - new( - Matches: null, - BackendRefs: new List - { - new("grpc-svc", 9090, null) - }, - Filters: null) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("grpc-app", "api-ns", route); - - // Assert - - yaml.Should().Contain("kind: GRPCRoute"); - yaml.Should().Contain("grpc.example.com"); - yaml.Should().Contain("port: 9090"); - } - - [Fact] - public void GenerateRoute_HttpRouteWithFilters_IncludesFilterBlocks() - { - // Arrange — HTTPRoute filters can add/remove headers, redirect, - // or rewrite URLs. This tests header modification filters. - - RouteSpec route = new( - Name: "api-route", - Type: "HTTP", - Hostnames: new List { "api.example.com" }, - GatewayRef: null, - Rules: new List - { - new( - Matches: new List - { - new("PathPrefix", "/api", null) - }, - BackendRefs: new List - { - new("api-svc", 80, null) - }, - Filters: new List - { - new("RequestHeaderModifier", new Dictionary - { - ["X-Forwarded-Proto"] = "https" - }, null, null) - }) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("api", "prod", route); - - // Assert - - yaml.Should().Contain("RequestHeaderModifier"); - yaml.Should().Contain("X-Forwarded-Proto"); - yaml.Should().Contain("https"); - } - - [Fact] - public void GenerateRoute_HttpRouteWithWeightedBackends_IncludesWeight() - { - // Arrange — Canary deployments split traffic between backends using weights. - - RouteSpec route = new( - Name: "canary-route", - Type: "HTTP", - Hostnames: new List { "app.example.com" }, - GatewayRef: null, - Rules: new List - { - new( - Matches: new List { new("PathPrefix", "/", null) }, - BackendRefs: new List - { - new("app-stable", 80, 90), - new("app-canary", 80, 10) - }, - Filters: null) - }); - - // Act - - string yaml = ManifestGenerator.GenerateRoute("app", "prod", route); - - // Assert - - yaml.Should().Contain("name: app-stable"); - yaml.Should().Contain("weight: 90"); - yaml.Should().Contain("name: app-canary"); - yaml.Should().Contain("weight: 10"); - } - - // ─── Legacy Service/Route backward compatibility ──────────────────── - - [Fact] - public void GenerateService_FromDeploymentSpec_StillWorksForBackwardCompat() - { - // Arrange — The old single-service path using DeploymentSpec's - // ServicePort/ContainerPort still works when no Services list is provided. - - DeploymentSpec spec = new( - Image: "api", - Tag: "v1", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null); - - // Act — The old overload should still exist. - - string yaml = ManifestGenerator.GenerateService("api", "dev", spec); - - // Assert - - yaml.Should().Contain("kind: Service"); - yaml.Should().Contain("port: 80"); - yaml.Should().Contain("targetPort: 8080"); - } - - // ─── Namespace Manifest ────────────────────────────────────────────── - - [Fact] - public void GenerateNamespace_ProducesValidNamespaceManifest() - { - // Act - - string yaml = ManifestGenerator.GenerateNamespace("my-app-dev"); - - // Assert - - yaml.Should().Contain("kind: Namespace"); - yaml.Should().Contain("name: my-app-dev"); - yaml.Should().Contain("app.kubernetes.io/managed-by: entkube"); - } - - // ─── ServiceAccount Manifest ───────────────────────────────────────── - - [Fact] - public void GenerateServiceAccount_ProducesValidManifest() - { - // Act - - string yaml = ManifestGenerator.GenerateServiceAccount("my-sa", "my-ns"); - - // Assert - - yaml.Should().Contain("kind: ServiceAccount"); - yaml.Should().Contain("name: my-sa"); - yaml.Should().Contain("namespace: my-ns"); - } - - // ─── PersistentVolumeClaim Manifest ────────────────────────────────── - - [Fact] - public void GeneratePersistentVolumeClaim_ProducesValidManifest() - { - // Act - - string yaml = ManifestGenerator.GeneratePersistentVolumeClaim("data-pvc", "my-ns", "5Gi"); - - // Assert - - yaml.Should().Contain("kind: PersistentVolumeClaim"); - yaml.Should().Contain("name: data-pvc"); - yaml.Should().Contain("namespace: my-ns"); - yaml.Should().Contain("storage: 5Gi"); - yaml.Should().Contain("ReadWriteOnce"); - } - - [Fact] - public void GeneratePersistentVolumeClaim_WithStorageClass_IncludesIt() - { - // Act - - string yaml = ManifestGenerator.GeneratePersistentVolumeClaim( - "data-pvc", "my-ns", "10Gi", "ReadWriteMany", "fast-ssd"); - - // Assert - - yaml.Should().Contain("storageClassName: fast-ssd"); - yaml.Should().Contain("ReadWriteMany"); - } - - // ─── Image Pull Secrets ────────────────────────────────────────────── - - [Fact] - public void GenerateDeployment_WithHarborRegistry_IncludesImagePullSecrets() - { - // Arrange — When an app uses Harbor as its image registry, the - // generated deployment should include an imagePullSecrets reference - // so Kubernetes can authenticate against the private registry. - - DeploymentSpec spec = new( - Image: "harbor.example.com/myproject/api", - Tag: "v1.0.0", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - ImageRegistry: new ImageRegistryConfig( - ImageRegistrySource.Harbor, - HarborDomain: "harbor.example.com", - HarborProject: "myproject", - PullSecretName: null)); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("my-api", "dev", spec); - - // Assert — The YAML should contain the auto-generated pull secret name. - - yaml.Should().Contain("imagePullSecrets:"); - yaml.Should().Contain("harbor-pull-myproject-my-api"); - } - - [Fact] - public void GenerateDeployment_WithCustomRegistry_IncludesSpecifiedPullSecret() - { - // Arrange — A custom registry with a manually specified pull secret name. - - DeploymentSpec spec = new( - Image: "ghcr.io/org/api", - Tag: "latest", - Replicas: 1, - ContainerPort: 8080, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null, - ImageRegistry: new ImageRegistryConfig( - ImageRegistrySource.Custom, - HarborDomain: null, - HarborProject: null, - PullSecretName: "my-ghcr-secret")); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("api", "prod", spec); - - // Assert - - yaml.Should().Contain("imagePullSecrets:"); - yaml.Should().Contain("my-ghcr-secret"); - } - - [Fact] - public void GenerateDeployment_WithPublicRegistry_OmitsImagePullSecrets() - { - // Arrange — A public registry needs no pull secret. - - DeploymentSpec spec = new( - Image: "nginx", - Tag: "latest", - Replicas: 1, - ContainerPort: 80, - ServicePort: 80, - HostName: null, - PathPrefix: null, - EnvironmentVariables: null, - Resources: null); - - // Act - - string yaml = ManifestGenerator.GenerateDeployment("web", "default", spec); - - // Assert — No imagePullSecrets section should appear. - - yaml.Should().NotContain("imagePullSecrets:"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs b/tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs deleted file mode 100644 index 20af07e..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioInstances; -using EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance; -using EntKube.Provisioning.Features.PostgresClusters; -using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for MinIO adoption and its integration with CNPG cluster provisioning. -/// MinIO must be adopted before CNPG clusters can be created — the create flow -/// resolves the MinIO details by reference (cluster ID) rather than raw values. -/// -public class MinioAdoptionTests -{ - private readonly InMemoryMinioInstanceRepository minioRepository; - private readonly InMemoryPostgresClusterRepository pgRepository; - private readonly Mock minioClient; - private readonly Mock cnpgClient; - - public MinioAdoptionTests() - { - minioRepository = new InMemoryMinioInstanceRepository(); - pgRepository = new InMemoryPostgresClusterRepository(); - minioClient = new Mock(); - cnpgClient = new Mock(); - } - - // ─── Adopt MinIO ────────────────────────────────────────────────────────── - - [Fact] - public async Task AdoptMinio_WithDiscoveredTenant_CreatesRunningInstance() - { - // Arrange — The K8s cluster has an existing MinIO tenant running. - - minioClient.Setup(c => c.DiscoverMinioAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new DiscoveredMinioInstance( - Name: "platform-minio", - Namespace: "minio-system", - Endpoint: "minio.minio-system.svc:9000", - ConsoleEndpoint: "minio-console.minio-system.svc:9001", - CredentialsSecret: "minio-root-credentials", - TotalCapacity: "1Ti", - UsedCapacity: "250Gi", - Buckets: new List { "loki-logs", "velero-backups" })); - - AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object); - - AdoptMinioInstanceRequest request = new( - ClusterId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — MinIO adopted and stored with all discovered details. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - IReadOnlyList all = await minioRepository.GetAllAsync(); - all.Should().HaveCount(1); - all[0].Name.Should().Be("platform-minio"); - all[0].Endpoint.Should().Be("minio.minio-system.svc:9000"); - all[0].Status.Should().Be(MinioInstanceStatus.Running); - all[0].Buckets.Should().Contain("loki-logs"); - all[0].Buckets.Should().Contain("velero-backups"); - } - - [Fact] - public async Task AdoptMinio_NoTenantFound_ReturnsFailure() - { - // Arrange — No MinIO tenant on this cluster. - - minioClient.Setup(c => c.DiscoverMinioAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((DiscoveredMinioInstance?)null); - - AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object); - - AdoptMinioInstanceRequest request = new(Guid.NewGuid(), "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("No MinIO"); - } - - [Fact] - public async Task AdoptMinio_AlreadyAdopted_ReturnsExistingId() - { - // Arrange — MinIO was already adopted for this cluster. - - Guid clusterId = Guid.NewGuid(); - - MinioInstance existing = MinioInstance.Adopt( - clusterId, "platform-minio", "minio-system", - "minio:9000", null, "secret", "500Gi", null, null); - await minioRepository.AddAsync(existing); - - minioClient.Setup(c => c.DiscoverMinioAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new DiscoveredMinioInstance( - "platform-minio", "minio-system", "minio:9000", - null, "secret", "500Gi", null, null)); - - AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object); - - AdoptMinioInstanceRequest request = new(clusterId, "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Returns the existing ID without creating a duplicate. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().Be(existing.Id); - - IReadOnlyList all = await minioRepository.GetAllAsync(); - all.Should().HaveCount(1); - } - - // ─── CNPG Create with MinIO Reference ───────────────────────────────────── - - [Fact] - public async Task CreatePostgresCluster_WithMinioInstanceId_ResolvesMinioConfig() - { - // Arrange — An adopted MinIO instance exists. When creating a CNPG cluster, - // we reference it by the cluster ID instead of providing raw details. - - Guid clusterId = Guid.NewGuid(); - - MinioInstance minio = MinioInstance.Adopt( - clusterId, "platform-minio", "minio-system", - "minio.minio-system.svc:9000", null, - "minio-root-credentials", "1Ti", null, - new List { "cnpg-backups" }); - await minioRepository.AddAsync(minio); - - CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository); - - CreatePostgresClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: clusterId, - Name: "prod-db", - Namespace: "cnpg-system", - PostgresVersion: "16.4", - Instances: 3, - StorageSize: "50Gi", - KubeConfig: "config", - ContextName: "ctx", - MinioEndpoint: null, - MinioBucket: "cnpg-backups", - MinioCredentialsSecret: null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — MinIO details resolved from the adopted instance. - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList clusters = await pgRepository.GetAllAsync(); - clusters.Should().HaveCount(1); - clusters[0].BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000"); - clusters[0].BackupConfig.CredentialsSecret.Should().Be("minio-root-credentials"); - clusters[0].BackupConfig.Bucket.Should().Be("cnpg-backups"); - - cnpgClient.Verify(c => c.CreateClusterAsync( - "config", "ctx", - It.Is(s => - s.MinioEndpoint == "minio.minio-system.svc:9000" && - s.MinioCredentialsSecret == "minio-root-credentials"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task CreatePostgresCluster_NoMinioAdopted_CreatesWithoutBackup() - { - // Arrange — No MinIO adopted for this cluster, and no explicit endpoint provided. - // The cluster should still be created, just without backup configuration. - - CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository); - - CreatePostgresClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "prod-db", - Namespace: "cnpg-system", - PostgresVersion: "16.4", - Instances: 3, - StorageSize: "50Gi", - KubeConfig: "config", - ContextName: "ctx", - MinioEndpoint: null, - MinioBucket: "backups", - MinioCredentialsSecret: null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Cluster is created successfully without backup. - - result.IsSuccess.Should().BeTrue(); - } - - [Fact] - public async Task CreatePostgresCluster_WithExplicitMinioDetails_StillWorks() - { - // Arrange — Explicit MinIO details provided (bypass adoption lookup). - - CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository); - - CreatePostgresClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "prod-db", - Namespace: "cnpg-system", - PostgresVersion: "16.4", - Instances: 3, - StorageSize: "50Gi", - KubeConfig: "config", - ContextName: "ctx", - MinioEndpoint: "custom-minio:9000", - MinioBucket: "custom-bucket", - MinioCredentialsSecret: "custom-secret"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Explicit values used directly. - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList clusters = await pgRepository.GetAllAsync(); - clusters[0].BackupConfig!.MinioEndpoint.Should().Be("custom-minio:9000"); - clusters[0].BackupConfig.Bucket.Should().Be("custom-bucket"); - clusters[0].BackupConfig.CredentialsSecret.Should().Be("custom-secret"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/MinioTenantAdoptionTests.cs b/tests/EntKube.Provisioning.Tests/Features/MinioTenantAdoptionTests.cs deleted file mode 100644 index 4940400..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/MinioTenantAdoptionTests.cs +++ /dev/null @@ -1,256 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants; -using EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for MinIO Tenant adoption — discovering existing MinIO Tenant CRDs on a -/// Kubernetes cluster and importing them into the platform's domain so they can be -/// viewed, managed, and monitored. This follows the same pattern as CNPG cluster -/// adoption: the Provisioning service scans the cluster, finds what's already running, -/// and creates domain aggregates for each tenant. -/// -public class MinioTenantAdoptionTests -{ - private readonly InMemoryMinioTenantRepository tenantRepository; - private readonly Mock tenantClient; - - public MinioTenantAdoptionTests() - { - tenantRepository = new InMemoryMinioTenantRepository(); - tenantClient = new Mock(); - } - - // ─── Adopt Tenants ────────────────────────────────────────────────────────── - - [Fact] - public async Task AdoptTenants_WithExistingTenants_ImportsThemAsRunning() - { - // Arrange — The Kubernetes cluster has two MinIO tenants already running: - // one for platform storage and one for application data. Neither of them - // exists in our repository yet, so adoption should discover and import both. - - Guid clusterId = Guid.NewGuid(); - - tenantClient.Setup(c => c.DiscoverTenantsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new( - Name: "platform-storage", - Namespace: "minio-platform", - Pools: new List - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }, - CurrentState: "Initialized", - Buckets: new List { "cnpg-backups", "loki-logs" }), - new( - Name: "app-storage", - Namespace: "minio-apps", - Pools: new List - { - new(Servers: 2, VolumesPerServer: 4, StorageSize: "50Gi", StorageClass: "standard") - }, - CurrentState: "Initialized", - Buckets: new List { "user-uploads" }) - }); - - AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); - - AdoptMinioTenantsRequest request = new( - ClusterId: clusterId, - KubeConfig: "kubeconfig-content", - ContextName: "prod-context"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Both tenants adopted with correct details. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(2); - - IReadOnlyList all = await tenantRepository.GetAllAsync(); - all.Should().HaveCount(2); - - MinioTenant platformTenant = all.First(t => t.Name == "platform-storage"); - platformTenant.ClusterId.Should().Be(clusterId); - platformTenant.Namespace.Should().Be("minio-platform"); - platformTenant.Status.Should().Be(MinioTenantStatus.Running); - platformTenant.Pools.Should().HaveCount(1); - platformTenant.Pools[0].Servers.Should().Be(4); - platformTenant.Pools[0].StorageSize.Should().Be("100Gi"); - - MinioTenant appTenant = all.First(t => t.Name == "app-storage"); - appTenant.Status.Should().Be(MinioTenantStatus.Running); - } - - [Fact] - public async Task AdoptTenants_NoTenantsOnCluster_ReturnsEmptyList() - { - // Arrange — The MinIO operator is installed but no tenants are deployed yet. - - tenantClient.Setup(c => c.DiscoverTenantsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List()); - - AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); - - AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Success with empty list, not a failure. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeEmpty(); - } - - [Fact] - public async Task AdoptTenants_AlreadyAdopted_DoesNotDuplicate() - { - // Arrange — One tenant was already adopted. Running adoption again should - // skip it and only import new tenants, not create duplicates. - - Guid clusterId = Guid.NewGuid(); - - MinioTenant existing = MinioTenant.Adopt( - clusterId: clusterId, - name: "platform-storage", - ns: "minio-platform", - pools: new List - { - new(4, 4, "100Gi", "ceph-block") - }); - - await tenantRepository.AddAsync(existing); - - tenantClient.Setup(c => c.DiscoverTenantsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new( - Name: "platform-storage", - Namespace: "minio-platform", - Pools: new List - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }, - CurrentState: "Initialized", - Buckets: new List { "cnpg-backups" }), - new( - Name: "new-tenant", - Namespace: "minio-new", - Pools: new List - { - new(Servers: 2, VolumesPerServer: 2, StorageSize: "50Gi", StorageClass: "standard") - }, - CurrentState: "Initialized", - Buckets: new List()) - }); - - AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); - - AdoptMinioTenantsRequest request = new(clusterId, "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Only one new tenant adopted, the existing one was skipped. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(2); - - IReadOnlyList all = await tenantRepository.GetAllAsync(); - all.Should().HaveCount(2); - } - - [Fact] - public async Task AdoptTenants_DegradedTenant_MarkedDegraded() - { - // Arrange — A tenant with state "Decommissioning" or some non-healthy - // state should be imported but marked as Degraded, not Running. - - tenantClient.Setup(c => c.DiscoverTenantsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new( - Name: "sick-tenant", - Namespace: "minio-sick", - Pools: new List - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard") - }, - CurrentState: "Decommissioning", - Buckets: new List()) - }); - - AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object); - - AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList all = await tenantRepository.GetAllAsync(); - all[0].Status.Should().Be(MinioTenantStatus.Degraded); - } - - // ─── Domain: MinioTenant.Adopt ────────────────────────────────────────────── - - [Fact] - public void Adopt_WithValidConfig_CreatesRunningTenant() - { - // Arrange — We discover a healthy tenant with known pools. - - List pools = new() - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }; - - // Act - - MinioTenant tenant = MinioTenant.Adopt( - clusterId: Guid.NewGuid(), - name: "discovered-tenant", - ns: "minio-discovered", - pools: pools); - - // Assert — Adopted tenants start as Running since they're already serving traffic. - - tenant.Id.Should().NotBe(Guid.Empty); - tenant.Name.Should().Be("discovered-tenant"); - tenant.Namespace.Should().Be("minio-discovered"); - tenant.Status.Should().Be(MinioTenantStatus.Running); - tenant.Pools.Should().HaveCount(1); - tenant.Pools[0].Servers.Should().Be(4); - } - - [Fact] - public void Adopt_WithEmptyName_Throws() - { - // A tenant with no name is not valid — discovery must provide a name. - - Action act = () => MinioTenant.Adopt( - Guid.NewGuid(), "", "ns", - new List { new(1, 1, "10Gi", "standard") }); - - act.Should().Throw(); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/MinioTenantCredentialParsingTests.cs b/tests/EntKube.Provisioning.Tests/Features/MinioTenantCredentialParsingTests.cs deleted file mode 100644 index fa91bdc..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/MinioTenantCredentialParsingTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using EntKube.Provisioning.Features.MinioTenants; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for parsing MinIO tenant credentials from the various secret formats -/// used by different MinIO Operator versions. The operator has changed its -/// credential storage format over time: -/// -/// - Legacy (v4): separate "accesskey" and "secretkey" keys in the K8s secret -/// - v5+: a single "config.env" key containing export statements -/// - Console: "CONSOLE_ACCESS_KEY" and "CONSOLE_SECRET_KEY" keys -/// -/// The credential parser must handle all formats so mc commands can authenticate -/// against tenants provisioned by any operator version. -/// -public class MinioTenantCredentialParsingTests -{ - // ─── config.env format (MinIO Operator v5+) ──────────────────────────── - - [Fact] - public void ParseConfigEnv_WithQuotedValues_ExtractsCredentials() - { - // The most common format — double-quoted values with export prefix. - - string configEnv = """ - export MINIO_ROOT_USER="minio-admin" - export MINIO_ROOT_PASSWORD="super-secret-password" - """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("minio-admin"); - result!.Value.secretKey.Should().Be("super-secret-password"); - } - - [Fact] - public void ParseConfigEnv_WithSingleQuotedValues_ExtractsCredentials() - { - // Some installations use single quotes. - - string configEnv = """ - export MINIO_ROOT_USER='admin' - export MINIO_ROOT_PASSWORD='p@ssw0rd' - """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("admin"); - result!.Value.secretKey.Should().Be("p@ssw0rd"); - } - - [Fact] - public void ParseConfigEnv_WithUnquotedValues_ExtractsCredentials() - { - // Unquoted values — less common but valid. - - string configEnv = """ - export MINIO_ROOT_USER=minio - export MINIO_ROOT_PASSWORD=minio123 - """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("minio"); - result!.Value.secretKey.Should().Be("minio123"); - } - - [Fact] - public void ParseConfigEnv_WithAdditionalEnvVars_IgnoresExtras() - { - // The config.env may contain other env vars beyond credentials. - // We should extract only the root user/password. - - string configEnv = """ - export MINIO_ROOT_USER="minio" - export MINIO_ROOT_PASSWORD="secret" - export MINIO_BROWSER="on" - export MINIO_STORAGE_CLASS_STANDARD="EC:2" - """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("minio"); - result!.Value.secretKey.Should().Be("secret"); - } - - [Fact] - public void ParseConfigEnv_MissingUser_ReturnsNull() - { - // If the user key is missing, we can't authenticate. - - string configEnv = """ - export MINIO_ROOT_PASSWORD="secret" - """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().BeNull(); - } - - [Fact] - public void ParseConfigEnv_MissingPassword_ReturnsNull() - { - // If the password key is missing, we can't authenticate. - - string configEnv = """ - export MINIO_ROOT_USER="admin" - """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().BeNull(); - } - - [Fact] - public void ParseConfigEnv_EmptyString_ReturnsNull() - { - // An empty config.env should not crash — just return null. - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(""); - - result.Should().BeNull(); - } - - [Fact] - public void ParseConfigEnv_WithWhitespaceAndEmptyLines_ExtractsCredentials() - { - // Real config files often have trailing newlines and inconsistent whitespace. - - string configEnv = "\n export MINIO_ROOT_USER=\"root-user\" \n\n export MINIO_ROOT_PASSWORD=\"root-pass\" \n\n"; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("root-user"); - result!.Value.secretKey.Should().Be("root-pass"); - } - - [Fact] - public void ParseConfigEnv_SingleLine_ExtractsCredentials() - { - // Some environments produce config.env as a single line with all exports - // separated by spaces. This happens with MinIO Operator v6 in some configurations. - - string configEnv = """export MINIO_ROOT_USER="minio-admin" export MINIO_ROOT_PASSWORD="sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7" export MINIO_BROWSER="on" export MINIO_PROMETHEUS_AUTH_TYPE="public" """; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("minio-admin"); - result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7"); - } - - [Fact] - public void ParseConfigEnv_RealWorldV6Format_ExtractsCredentials() - { - // Exact format from a real MinIO Operator v6 installation. - - string configEnv = "export MINIO_ROOT_USER=\"minio-admin\"\nexport MINIO_ROOT_PASSWORD=\"sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7\"\nexport MINIO_BROWSER=\"on\"\nexport MINIO_PROMETHEUS_AUTH_TYPE=\"public\"\n"; - - // Act - - (string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv); - - // Assert - - result.Should().NotBeNull(); - result!.Value.accessKey.Should().Be("minio-admin"); - result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/MinioTenantProvisioningTests.cs b/tests/EntKube.Provisioning.Tests/Features/MinioTenantProvisioningTests.cs deleted file mode 100644 index a1ec29d..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/MinioTenantProvisioningTests.cs +++ /dev/null @@ -1,308 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MinioTenants; -using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant; -using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant; -using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for MinIO Tenant provisioning — the full lifecycle of creating, configuring, -/// and deleting MinIO tenants on a Kubernetes cluster. Unlike MinIO adoption (which -/// discovers existing tenants), this provisions new tenants from scratch via the -/// MinIO Operator's Tenant CRD. -/// -public class MinioTenantProvisioningTests -{ - private readonly InMemoryMinioTenantRepository tenantRepository; - private readonly Mock tenantClient; - - public MinioTenantProvisioningTests() - { - tenantRepository = new InMemoryMinioTenantRepository(); - tenantClient = new Mock(); - } - - // ─── Create Tenant ────────────────────────────────────────────────────────── - - [Fact] - public async Task CreateTenant_WithValidConfig_ProvisionsTenantOnCluster() - { - // Arrange — A platform admin wants a new MinIO tenant with 4 servers, - // each with 4 disks of 100Gi, using the ceph-block storage class. - - tenantClient.Setup(c => c.ApplyTenantAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - CreateMinioTenantRequest request = new( - ClusterId: Guid.NewGuid(), - KubeConfig: "kubeconfig-content", - ContextName: "prod-context", - Name: "platform-storage", - Namespace: "minio-tenant-1", - Pools: new List - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Tenant created in domain and applied to K8s cluster. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - IReadOnlyList all = await tenantRepository.GetAllAsync(); - all.Should().HaveCount(1); - all[0].Name.Should().Be("platform-storage"); - all[0].Namespace.Should().Be("minio-tenant-1"); - all[0].Status.Should().Be(MinioTenantStatus.Provisioning); - all[0].Pools.Should().HaveCount(1); - all[0].Pools[0].Servers.Should().Be(4); - all[0].Pools[0].VolumesPerServer.Should().Be(4); - - // Verify the K8s client was called to apply the Tenant CRD. - - tenantClient.Verify(c => c.ApplyTenantAsync( - "kubeconfig-content", "prod-context", - It.Is(t => t.Name == "platform-storage"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task CreateTenant_WithMultiplePools_AllPoolsApplied() - { - // Arrange — Heterogeneous pools: fast NVMe for hot data, HDD for warm. - - tenantClient.Setup(c => c.ApplyTenantAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - CreateMinioTenantRequest request = new( - ClusterId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx", - Name: "multi-tier", - Namespace: "minio-multi", - Pools: new List - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"), - new(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk") - }); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList all = await tenantRepository.GetAllAsync(); - all[0].Pools.Should().HaveCount(2); - } - - [Fact] - public async Task CreateTenant_WithInvalidConfig_ReturnsFailure() - { - // Arrange — Zero servers is not valid. - - CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - CreateMinioTenantRequest request = new( - ClusterId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx", - Name: "bad-tenant", - Namespace: "minio-bad", - Pools: new List - { - new(Servers: 0, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard") - }); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Domain validation catches the bad config. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("server"); - } - - [Fact] - public async Task CreateTenant_KubernetesClientFails_ReturnsFailure() - { - // Arrange — Network issue or RBAC problem prevents applying the CRD. - - tenantClient.Setup(c => c.ApplyTenantAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("Forbidden: RBAC denied")); - - CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - CreateMinioTenantRequest request = new( - ClusterId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx", - Name: "tenant", - Namespace: "minio-ns", - Pools: new List - { - new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard") - }); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Failure reported without tenant being persisted. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("RBAC"); - - IReadOnlyList all = await tenantRepository.GetAllAsync(); - all.Should().BeEmpty(); - } - - // ─── Configure Tenant ────────────────────────────────────────────────────── - - [Fact] - public async Task ConfigureTenant_UpdatesPools_AndReapplies() - { - // Arrange — Existing tenant needs scale-out: from 4 to 8 servers. - - MinioTenant existing = MinioTenant.Create( - Guid.NewGuid(), "platform-storage", "minio-tenant-1", - new List { new(4, 4, "100Gi", "ceph-block") }); - existing.MarkRunning(); - await tenantRepository.AddAsync(existing); - - tenantClient.Setup(c => c.ApplyTenantAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - ConfigureMinioTenantRequest request = new( - TenantId: existing.Id, - KubeConfig: "config", - ContextName: "ctx", - Pools: new List - { - new(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block") - }); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Pools updated, status back to Provisioning, K8s re-applied. - - result.IsSuccess.Should().BeTrue(); - - MinioTenant? updated = await tenantRepository.GetByIdAsync(existing.Id); - updated.Should().NotBeNull(); - updated!.Pools[0].Servers.Should().Be(8); - updated.Status.Should().Be(MinioTenantStatus.Provisioning); - - tenantClient.Verify(c => c.ApplyTenantAsync( - "config", "ctx", - It.Is(t => t.Pools[0].Servers == 8), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ConfigureTenant_NotFound_ReturnsFailure() - { - // Arrange - - ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - ConfigureMinioTenantRequest request = new( - TenantId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx", - Pools: new List { new(4, 4, "100Gi", "standard") }); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── Delete Tenant ────────────────────────────────────────────────────────── - - [Fact] - public async Task DeleteTenant_RemovesFromClusterAndMarksDecommissioned() - { - // Arrange — Platform admin tears down a tenant. - - MinioTenant existing = MinioTenant.Create( - Guid.NewGuid(), "old-tenant", "minio-old", - new List { new(4, 4, "100Gi", "standard") }); - existing.MarkRunning(); - await tenantRepository.AddAsync(existing); - - tenantClient.Setup(c => c.DeleteTenantAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - DeleteMinioTenantRequest request = new( - TenantId: existing.Id, - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Marked decommissioned and deleted from K8s. - - result.IsSuccess.Should().BeTrue(); - - MinioTenant? tenant = await tenantRepository.GetByIdAsync(existing.Id); - tenant.Should().NotBeNull(); - tenant!.Status.Should().Be(MinioTenantStatus.Decommissioned); - - tenantClient.Verify(c => c.DeleteTenantAsync( - "config", "ctx", "old-tenant", "minio-old", - It.IsAny()), Times.Once); - } - - [Fact] - public async Task DeleteTenant_NotFound_ReturnsFailure() - { - // Arrange - - DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object); - - DeleteMinioTenantRequest request = new(Guid.NewGuid(), "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/MongoClusterFeatureTests.cs b/tests/EntKube.Provisioning.Tests/Features/MongoClusterFeatureTests.cs deleted file mode 100644 index 43ec86b..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/MongoClusterFeatureTests.cs +++ /dev/null @@ -1,642 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.MongoClusters; -using EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.AddDatabase; -using EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster; -using EntKube.Provisioning.Features.MongoClusters.RestoreBackup; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for all MongoDB cluster management features. Each test verifies that -/// the handler correctly orchestrates domain logic + K8s client interactions. -/// -public class MongoClusterFeatureTests -{ - private readonly InMemoryMongoClusterRepository repository; - private readonly InMemoryMinioInstanceRepository minioRepository; - private readonly Mock mongoClient; - - public MongoClusterFeatureTests() - { - repository = new InMemoryMongoClusterRepository(); - minioRepository = new InMemoryMinioInstanceRepository(); - mongoClient = new Mock(); - } - - // ─── Create Cluster ─────────────────────────────────────────────────────── - - [Fact] - public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient() - { - // Arrange — A platform admin wants a new 3-member MongoDB replica set - // with mongodump backups to MinIO. - - CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository); - - CreateMongoClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "platform-mongo", - Namespace: "mongodb-system", - MongoVersion: "7.0.12", - Members: 3, - StorageSize: "50Gi", - KubeConfig: "apiVersion: v1\nkind: Config", - ContextName: "prod-ctx", - MinioEndpoint: "minio.minio-system.svc:9000", - MinioBucket: "mongo-backups", - MinioCredentialsSecret: "minio-mongo-creds"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Cluster created in repository and K8s client called. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - IReadOnlyList clusters = await repository.GetAllAsync(); - clusters.Should().HaveCount(1); - clusters[0].Name.Should().Be("platform-mongo"); - clusters[0].Status.Should().Be(MongoClusterStatus.Provisioning); - - mongoClient.Verify(c => c.CreateClusterAsync( - "apiVersion: v1\nkind: Config", - "prod-ctx", - It.Is(s => - s.Name == "platform-mongo" && - s.MongoVersion == "7.0.12" && - s.Members == 3 && - s.MinioEndpoint == "minio.minio-system.svc:9000"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task CreateCluster_WithMissingName_ReturnsFailure() - { - // Arrange - - CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository); - - CreateMongoClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "", - Namespace: "ns", - MongoVersion: "7.0.12", - Members: 3, - StorageSize: "50Gi", - KubeConfig: "config", - ContextName: "ctx", - MinioEndpoint: "minio:9000", - MinioBucket: "bucket", - MinioCredentialsSecret: "secret"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("name"); - } - - // ─── Adopt Cluster ──────────────────────────────────────────────────────── - - [Fact] - public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll() - { - // Arrange — The K8s cluster has 2 existing MongoDBCommunity clusters. - - mongoClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", - new List { "app_db", "analytics_db" }, - "minio.minio-system.svc:9000", "mongo-backups", "minio-creds"), - new("staging-mongo", "mongodb-staging", "6.0.16", 1, "20Gi", - new List { "staging_db" }, - "minio.minio-system.svc:9000", "mongo-backups-staging", "minio-creds") - }); - - AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); - - AdoptMongoClustersRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Both clusters adopted and stored in Running state. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(2); - - IReadOnlyList all = await repository.GetAllAsync(); - all.Should().HaveCount(2); - all[0].Status.Should().Be(MongoClusterStatus.Running); - all[0].Databases.Should().Contain("app_db"); - all[1].Databases.Should().Contain("staging_db"); - } - - [Fact] - public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList() - { - // Arrange — No MongoDBCommunity clusters found on K8s. - - mongoClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List()); - - AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); - - AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeEmpty(); - } - - // ─── Add Database ───────────────────────────────────────────────────────── - - [Fact] - public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s() - { - // Arrange — A running MongoDB cluster exists. - - MongoCluster mongoCluster = MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - await repository.AddAsync(mongoCluster); - - AddMongoDatabaseHandler handler = new(repository, mongoClient.Object); - - AddMongoDatabaseRequest request = new( - MongoClusterId: mongoCluster.Id, - DatabaseName: "new_app_db", - OwnerRole: "app_user", - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); - updated!.Databases.Should().Contain("new_app_db"); - - mongoClient.Verify(c => c.CreateDatabaseAsync( - "config", "ctx", "prod-mongo", "mongodb-system", "new_app_db", "app_user", - It.IsAny()), Times.Once); - } - - [Fact] - public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure() - { - // Arrange - - AddMongoDatabaseHandler handler = new(repository, mongoClient.Object); - - AddMongoDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── Upgrade Cluster ────────────────────────────────────────────────────── - - [Fact] - public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s() - { - // Arrange — A running cluster at version 6.0.16. - - MongoCluster mongoCluster = MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "6.0.16", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - await repository.AddAsync(mongoCluster); - - mongoClient.Setup(c => c.GetAvailableVersionsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { "6.0.16", "6.0.17", "7.0.12", "7.0.14" }); - - UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object); - - UpgradeMongoClusterRequest request = new( - MongoClusterId: mongoCluster.Id, - TargetVersion: "7.0.12", - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Domain transitions to Upgrading, K8s client called. - - result.IsSuccess.Should().BeTrue(); - - MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); - updated!.Status.Should().Be(MongoClusterStatus.Upgrading); - updated.TargetVersion.Should().Be("7.0.12"); - - mongoClient.Verify(c => c.UpgradeClusterAsync( - "config", "ctx", "prod-mongo", "mongodb-system", "7.0.12", - It.IsAny()), Times.Once); - } - - [Fact] - public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure() - { - // Arrange — Version 99.0 doesn't exist. - - MongoCluster mongoCluster = MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - await repository.AddAsync(mongoCluster); - - mongoClient.Setup(c => c.GetAvailableVersionsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { "7.0.12", "7.0.14" }); - - UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object); - - UpgradeMongoClusterRequest request = new(mongoCluster.Id, "99.0", "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not available"); - } - - // ─── Configure Cluster ──────────────────────────────────────────────────── - - [Fact] - public async Task ConfigureCluster_ScaleMembers_UpdatesK8sAndDomain() - { - // Arrange — Scale a running cluster from 3 to 5 members. - - MongoCluster mongoCluster = MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - await repository.AddAsync(mongoCluster); - - ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object); - - ConfigureMongoClusterRequest request = new( - MongoClusterId: mongoCluster.Id, - Members: 5, - StorageSize: null, - BackupSchedule: null, - BackupRetentionDays: null, - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); - updated!.Members.Should().Be(5); - - mongoClient.Verify(c => c.UpdateClusterAsync( - "config", "ctx", - It.Is(s => s.Members == 5), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesBackupCronJob() - { - // Arrange — Change backup schedule to every 6 hours with 30-day retention. - - MongoCluster mongoCluster = MongoCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - mongoCluster.MarkRunning(); - await repository.AddAsync(mongoCluster); - - ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object); - - ConfigureMongoClusterRequest request = new( - MongoClusterId: mongoCluster.Id, - Members: null, - StorageSize: null, - BackupSchedule: "0 */6 * * *", - BackupRetentionDays: 30, - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id); - updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); - updated.BackupConfig.RetentionDays.Should().Be(30); - - mongoClient.Verify(c => c.ConfigureScheduledBackupAsync( - "config", "ctx", "prod-mongo", "mongodb-system", "0 */6 * * *", 30, - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ConfigureCluster_NonExistent_ReturnsFailure() - { - // Arrange - - ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object); - - ConfigureMongoClusterRequest request = new( - Guid.NewGuid(), Members: 5, StorageSize: null, - BackupSchedule: null, BackupRetentionDays: null, - KubeConfig: "config", ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── Adopt De-duplication ───────────────────────────────────────────────── - - [Fact] - public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate() - { - // Arrange — The platform already has a cluster adopted. Re-running - // discovery should not create a duplicate but update databases. - - Guid clusterId = Guid.NewGuid(); - - MongoCluster existing = MongoCluster.Adopt( - Guid.NewGuid(), clusterId, "prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", - "minio:9000", "mongo-backups", "minio-creds", - databases: new List { "app_db" }); - - await repository.AddAsync(existing); - - mongoClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", - new List { "app_db", "analytics_db" }, - "minio:9000", "mongo-backups", "minio-creds"), - new("new-mongo", "mongodb-staging", "6.0.17", 1, "20Gi", - new List { "staging_db" }, - "minio:9000", "mongo-backups-staging", "minio-creds") - }); - - AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); - - AdoptMongoClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Only the new cluster is adopted, the existing one is updated. - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList all = await repository.GetByClusterIdAsync(clusterId); - all.Should().HaveCount(2); - all.Should().ContainSingle(c => c.Name == "prod-mongo"); - all.Should().ContainSingle(c => c.Name == "new-mongo"); - - // The existing cluster's databases should be updated. - - MongoCluster? updated = all.First(c => c.Name == "prod-mongo"); - updated.Databases.Should().Contain("analytics_db"); - } - - [Fact] - public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults() - { - // Arrange — A MongoDBCommunity cluster with no MinIO backup configured. - - mongoClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("dev-mongo", "default", "7.0.12", 1, "10Gi", - new List { "myapp" }, - null, null, null) - }); - - AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); - - AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Cluster adopted with default MinIO settings. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(1); - - IReadOnlyList all = await repository.GetAllAsync(); - all[0].Name.Should().Be("dev-mongo"); - all[0].Status.Should().Be(MongoClusterStatus.Running); - } - - // ─── Restore Backup ─────────────────────────────────────────────────────── - - [Fact] - public async Task RestoreBackup_WithValidCluster_CallsMongoClient() - { - // Arrange — A running cluster with a backup we want to restore from. - - MongoCluster mongoCluster = MongoCluster.Adopt( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret", - databases: new List { "app_db" }); - - await repository.AddAsync(mongoCluster); - - RestoreMongoBackupHandler handler = new(repository, mongoClient.Object); - - RestoreMongoBackupRequest request = new( - MongoClusterId: mongoCluster.Id, - BackupName: "prod-mongo-dump-20250510120000", - RestoredClusterName: "prod-mongo-restored", - KubeConfig: "config", - ContextName: "ctx", - TargetTime: null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — A new cluster should be created from the backup. - - result.IsSuccess.Should().BeTrue(); - - mongoClient.Verify(c => c.RestoreBackupAsync( - "config", "ctx", - It.Is(s => - s.SourceClusterName == "prod-mongo" && - s.RestoredClusterName == "prod-mongo-restored" && - s.BackupName == "prod-mongo-dump-20250510120000" && - s.Namespace == "mongodb-system"), - It.IsAny()), Times.Once); - - // The restored cluster should be persisted as a new aggregate. - - IReadOnlyList all = await repository.GetAllAsync(); - all.Should().HaveCount(2); - all.Should().ContainSingle(c => c.Name == "prod-mongo-restored"); - } - - [Fact] - public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime() - { - // Arrange — Restore to a specific point in time using oplog replay. - - MongoCluster mongoCluster = MongoCluster.Adopt( - Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi", - "minio:9000", "bucket", "secret"); - - await repository.AddAsync(mongoCluster); - - RestoreMongoBackupHandler handler = new(repository, mongoClient.Object); - - DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero); - - RestoreMongoBackupRequest request = new( - MongoClusterId: mongoCluster.Id, - BackupName: null, - RestoredClusterName: "prod-mongo-pitr", - KubeConfig: "config", - ContextName: "ctx", - TargetTime: targetTime); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - mongoClient.Verify(c => c.RestoreBackupAsync( - "config", "ctx", - It.Is(s => - s.TargetTime == targetTime && - s.RestoredClusterName == "prod-mongo-pitr"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure() - { - // Arrange - - RestoreMongoBackupHandler handler = new(repository, mongoClient.Object); - - RestoreMongoBackupRequest request = new( - Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── List Live Databases ────────────────────────────────────────────────── - - [Fact] - public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases() - { - // Arrange — Discovery from the CRD spec only finds configured databases, - // but the live MongoDB has additional databases created via shell. - - mongoClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi", - new List { "app_db" }, - "minio:9000", "mongo-backups", "minio-creds") - }); - - // The live database listing discovers additional databases. - - mongoClient.Setup(c => c.ListLiveDatabasesAsync( - It.IsAny(), It.IsAny(), - "prod-mongo", "mongodb-system", It.IsAny())) - .ReturnsAsync(new List { "app_db", "analytics_db", "reporting_db" }); - - AdoptMongoClustersHandler handler = new(repository, mongoClient.Object); - - AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — All three databases should be imported. - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList all = await repository.GetAllAsync(); - all[0].Databases.Should().HaveCount(3); - all[0].Databases.Should().Contain("app_db"); - all[0].Databases.Should().Contain("analytics_db"); - all[0].Databases.Should().Contain("reporting_db"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/PostgresClusterFeatureTests.cs b/tests/EntKube.Provisioning.Tests/Features/PostgresClusterFeatureTests.cs deleted file mode 100644 index bb71ce0..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/PostgresClusterFeatureTests.cs +++ /dev/null @@ -1,646 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.PostgresClusters; -using EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.AddDatabase; -using EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster; -using EntKube.Provisioning.Features.PostgresClusters.RestoreBackup; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for all PostgreSQL cluster management features. Each test verifies that -/// the handler correctly orchestrates domain logic + K8s client interactions. -/// -public class PostgresClusterFeatureTests -{ - private readonly InMemoryPostgresClusterRepository repository; - private readonly InMemoryMinioInstanceRepository minioRepository; - private readonly Mock cnpgClient; - - public PostgresClusterFeatureTests() - { - repository = new InMemoryPostgresClusterRepository(); - minioRepository = new InMemoryMinioInstanceRepository(); - cnpgClient = new Mock(); - } - - // ─── Create Cluster ─────────────────────────────────────────────────────── - - [Fact] - public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient() - { - // Arrange — A platform admin wants a new 3-instance PostgreSQL cluster - // with WAL archiving to MinIO. - - CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository); - - CreatePostgresClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "platform-db", - Namespace: "cnpg-system", - PostgresVersion: "16.4", - Instances: 3, - StorageSize: "50Gi", - KubeConfig: "apiVersion: v1\nkind: Config", - ContextName: "prod-ctx", - MinioEndpoint: "minio.minio-system.svc:9000", - MinioBucket: "cnpg-backups", - MinioCredentialsSecret: "minio-cnpg-creds"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Cluster created in repository and K8s client called. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - IReadOnlyList clusters = await repository.GetAllAsync(); - clusters.Should().HaveCount(1); - clusters[0].Name.Should().Be("platform-db"); - clusters[0].Status.Should().Be(PostgresClusterStatus.Provisioning); - - cnpgClient.Verify(c => c.CreateClusterAsync( - "apiVersion: v1\nkind: Config", - "prod-ctx", - It.Is(s => - s.Name == "platform-db" && - s.PostgresVersion == "16.4" && - s.Instances == 3 && - s.MinioEndpoint == "minio.minio-system.svc:9000"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task CreateCluster_WithMissingName_ReturnsFailure() - { - // Arrange - - CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository); - - CreatePostgresClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "", - Namespace: "ns", - PostgresVersion: "16.4", - Instances: 3, - StorageSize: "50Gi", - KubeConfig: "config", - ContextName: "ctx", - MinioEndpoint: "minio:9000", - MinioBucket: "bucket", - MinioCredentialsSecret: "secret"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("name"); - } - - // ─── Adopt Cluster ──────────────────────────────────────────────────────── - - [Fact] - public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll() - { - // Arrange — The K8s cluster has 2 existing CNPG clusters. We adopt them - // so the platform is aware of what's already running. - - cnpgClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("prod-db", "cnpg-system", "16.4", 3, "100Gi", - new List { "app_db", "analytics_db" }, - "minio.minio-system.svc:9000", "pg-backups", "minio-creds", true), - new("staging-db", "cnpg-staging", "15.6", 1, "20Gi", - new List { "staging_db" }, - "minio.minio-system.svc:9000", "pg-backups-staging", "minio-creds", true) - }); - - AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); - - AdoptPostgresClustersRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Both clusters adopted and stored in Running state. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(2); - - IReadOnlyList all = await repository.GetAllAsync(); - all.Should().HaveCount(2); - all[0].Status.Should().Be(PostgresClusterStatus.Running); - all[0].Databases.Should().Contain("app_db"); - all[1].Databases.Should().Contain("staging_db"); - } - - [Fact] - public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList() - { - // Arrange — No CNPG clusters found on the K8s cluster. - - cnpgClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List()); - - AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); - - AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeEmpty(); - } - - // ─── Add Database ───────────────────────────────────────────────────────── - - [Fact] - public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s() - { - // Arrange — A running PostgreSQL cluster exists. - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - await repository.AddAsync(pgCluster); - - AddDatabaseHandler handler = new(repository, cnpgClient.Object); - - AddDatabaseRequest request = new( - PostgresClusterId: pgCluster.Id, - DatabaseName: "new_app_db", - OwnerRole: "app_user", - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); - updated!.Databases.Should().Contain("new_app_db"); - - cnpgClient.Verify(c => c.CreateDatabaseAsync( - "config", "ctx", "prod-db", "cnpg-system", "new_app_db", "app_user", - It.IsAny()), Times.Once); - } - - [Fact] - public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure() - { - // Arrange - - AddDatabaseHandler handler = new(repository, cnpgClient.Object); - - AddDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── Upgrade Cluster ────────────────────────────────────────────────────── - - [Fact] - public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s() - { - // Arrange — A running cluster at version 15.6. - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "15.6", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - await repository.AddAsync(pgCluster); - - cnpgClient.Setup(c => c.GetAvailableVersionsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { "15.6", "15.7", "16.4", "16.5" }); - - UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object); - - UpgradePostgresClusterRequest request = new( - PostgresClusterId: pgCluster.Id, - TargetVersion: "16.4", - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Domain transitions to Upgrading, K8s client called. - - result.IsSuccess.Should().BeTrue(); - - PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); - updated!.Status.Should().Be(PostgresClusterStatus.Upgrading); - updated.TargetVersion.Should().Be("16.4"); - - cnpgClient.Verify(c => c.UpgradeClusterAsync( - "config", "ctx", "prod-db", "cnpg-system", "16.4", - It.IsAny()), Times.Once); - } - - [Fact] - public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure() - { - // Arrange — Version 99.0 doesn't exist. - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - await repository.AddAsync(pgCluster); - - cnpgClient.Setup(c => c.GetAvailableVersionsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { "16.4", "16.5" }); - - UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object); - - UpgradePostgresClusterRequest request = new(pgCluster.Id, "99.0", "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not available"); - } - - // ─── Configure Cluster ──────────────────────────────────────────────────── - - [Fact] - public async Task ConfigureCluster_ScaleInstances_UpdatesK8sAndDomain() - { - // Arrange — Scale a running cluster from 3 to 5 instances. - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - await repository.AddAsync(pgCluster); - - ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object); - - ConfigurePostgresClusterRequest request = new( - PostgresClusterId: pgCluster.Id, - Instances: 5, - StorageSize: null, - BackupSchedule: null, - BackupRetentionDays: null, - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); - updated!.Instances.Should().Be(5); - - cnpgClient.Verify(c => c.UpdateClusterAsync( - "config", "ctx", - It.Is(s => s.Instances == 5), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesScheduledBackup() - { - // Arrange — Change backup schedule to every 6 hours with 30-day retention. - - PostgresCluster pgCluster = PostgresCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - pgCluster.MarkRunning(); - await repository.AddAsync(pgCluster); - - ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object); - - ConfigurePostgresClusterRequest request = new( - PostgresClusterId: pgCluster.Id, - Instances: null, - StorageSize: null, - BackupSchedule: "0 */6 * * *", - BackupRetentionDays: 30, - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id); - updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *"); - updated.BackupConfig.RetentionDays.Should().Be(30); - - cnpgClient.Verify(c => c.ConfigureScheduledBackupAsync( - "config", "ctx", "prod-db", "cnpg-system", "0 */6 * * *", 30, - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ConfigureCluster_NonExistent_ReturnsFailure() - { - // Arrange - - ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object); - - ConfigurePostgresClusterRequest request = new( - Guid.NewGuid(), Instances: 5, StorageSize: null, - BackupSchedule: null, BackupRetentionDays: null, - KubeConfig: "config", ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── Adopt De-duplication ───────────────────────────────────────────────── - - [Fact] - public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate() - { - // Arrange — The platform already has a cluster with the same name and - // namespace adopted. Re-running discovery should not create a duplicate. - - Guid clusterId = Guid.NewGuid(); - - PostgresCluster existing = PostgresCluster.Adopt( - Guid.NewGuid(), clusterId, "prod-db", "cnpg-system", "16.4", 3, "100Gi", - "minio:9000", "pg-backups", "minio-creds", - databases: new List { "app_db" }); - - await repository.AddAsync(existing); - - cnpgClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("prod-db", "cnpg-system", "16.4", 3, "100Gi", - new List { "app_db", "analytics_db" }, - "minio:9000", "pg-backups", "minio-creds", true), - new("new-db", "cnpg-staging", "15.8", 1, "20Gi", - new List { "staging_db" }, - "minio:9000", "pg-backups-staging", "minio-creds", true) - }); - - AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); - - AdoptPostgresClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Only the new cluster is adopted, the existing one is updated. - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList all = await repository.GetByClusterIdAsync(clusterId); - all.Should().HaveCount(2); - all.Should().ContainSingle(c => c.Name == "prod-db"); - all.Should().ContainSingle(c => c.Name == "new-db"); - - // The existing cluster's databases should be updated with newly discovered ones. - - PostgresCluster? updated = all.First(c => c.Name == "prod-db"); - updated.Databases.Should().Contain("analytics_db"); - } - - [Fact] - public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults() - { - // Arrange — A CNPG cluster that has no MinIO backup configured. - // The platform should still adopt it with default backup settings. - - cnpgClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("dev-db", "default", "16.4", 1, "10Gi", - new List { "myapp" }, - null, null, null, false) - }); - - AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); - - AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Cluster adopted with default MinIO settings. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(1); - - IReadOnlyList all = await repository.GetAllAsync(); - all[0].Name.Should().Be("dev-db"); - all[0].Status.Should().Be(PostgresClusterStatus.Running); - } - - // ─── Restore Backup ─────────────────────────────────────────────────────── - - [Fact] - public async Task RestoreBackup_WithValidCluster_CallsCnpgClient() - { - // Arrange — A running cluster with a backup we want to restore from. - // Restoring in CNPG means creating a new cluster from a recovery source. - - PostgresCluster pgCluster = PostgresCluster.Adopt( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret", - databases: new List { "app_db" }); - - await repository.AddAsync(pgCluster); - - RestoreBackupHandler handler = new(repository, cnpgClient.Object); - - RestoreBackupRequest request = new( - PostgresClusterId: pgCluster.Id, - BackupName: "prod-db-manual-20250510120000", - RestoredClusterName: "prod-db-restored", - KubeConfig: "config", - ContextName: "ctx", - TargetTime: null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — A new cluster should be created from the backup. - - result.IsSuccess.Should().BeTrue(); - - cnpgClient.Verify(c => c.RestoreBackupAsync( - "config", "ctx", - It.Is(s => - s.SourceClusterName == "prod-db" && - s.RestoredClusterName == "prod-db-restored" && - s.BackupName == "prod-db-manual-20250510120000" && - s.Namespace == "cnpg-system"), - It.IsAny()), Times.Once); - - // The restored cluster should be persisted as a new aggregate. - - IReadOnlyList all = await repository.GetAllAsync(); - all.Should().HaveCount(2); - all.Should().ContainSingle(c => c.Name == "prod-db-restored"); - } - - [Fact] - public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime() - { - // Arrange — Restore to a specific point in time (PITR). - - PostgresCluster pgCluster = PostgresCluster.Adopt( - Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi", - "minio:9000", "bucket", "secret"); - - await repository.AddAsync(pgCluster); - - RestoreBackupHandler handler = new(repository, cnpgClient.Object); - - DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero); - - RestoreBackupRequest request = new( - PostgresClusterId: pgCluster.Id, - BackupName: null, - RestoredClusterName: "prod-db-pitr", - KubeConfig: "config", - ContextName: "ctx", - TargetTime: targetTime); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - - cnpgClient.Verify(c => c.RestoreBackupAsync( - "config", "ctx", - It.Is(s => - s.TargetTime == targetTime && - s.RestoredClusterName == "prod-db-pitr"), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure() - { - // Arrange - - RestoreBackupHandler handler = new(repository, cnpgClient.Object); - - RestoreBackupRequest request = new( - Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - // ─── List Live Databases ────────────────────────────────────────────────── - - [Fact] - public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases() - { - // Arrange — Discovery from the CNPG CR spec only finds the bootstrap - // database, but the live cluster has additional databases created via SQL. - // The adoption should include all of them. - - cnpgClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("prod-db", "cnpg-system", "16.4", 3, "100Gi", - new List { "app_db" }, - "minio:9000", "pg-backups", "minio-creds", true) - }); - - // The live database listing discovers additional databases. - - cnpgClient.Setup(c => c.ListLiveDatabasesAsync( - It.IsAny(), It.IsAny(), - "prod-db", "cnpg-system", It.IsAny())) - .ReturnsAsync(new List { "app_db", "analytics_db", "reporting_db" }); - - AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object); - - AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — All databases from the live cluster are included. - - result.IsSuccess.Should().BeTrue(); - - IReadOnlyList all = await repository.GetAllAsync(); - all[0].Databases.Should().HaveCount(3); - all[0].Databases.Should().Contain("app_db"); - all[0].Databases.Should().Contain("analytics_db"); - all[0].Databases.Should().Contain("reporting_db"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/RedisClusterFeatureTests.cs b/tests/EntKube.Provisioning.Tests/Features/RedisClusterFeatureTests.cs deleted file mode 100644 index 5bc81d6..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/RedisClusterFeatureTests.cs +++ /dev/null @@ -1,290 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.RedisClusters; -using EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster; -using EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; -using Moq; - -namespace EntKube.Provisioning.Tests.Features; - -/// -/// Tests for all Redis cluster management features. Each test verifies that -/// the handler correctly orchestrates domain logic + K8s client interactions. -/// -public class RedisClusterFeatureTests -{ - private readonly InMemoryRedisClusterRepository repository; - private readonly Mock redisClient; - - public RedisClusterFeatureTests() - { - repository = new InMemoryRedisClusterRepository(); - redisClient = new Mock(); - } - - // ─── Create Cluster ─────────────────────────────────────────────────────── - - [Fact] - public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient() - { - // Arrange — A platform admin wants a new 3-replica Redis instance - // with sentinel enabled for HA. - - CreateRedisClusterHandler handler = new(repository, redisClient.Object); - - CreateRedisClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "platform-cache", - Namespace: "redis", - RedisVersion: "7.2.5", - Replicas: 3, - StorageSize: "10Gi", - SentinelEnabled: true, - KubeConfig: "apiVersion: v1\nkind: Config", - ContextName: "prod-ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Cluster created in repository and K8s client called. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBe(Guid.Empty); - - IReadOnlyList clusters = await repository.GetAllAsync(); - clusters.Should().HaveCount(1); - clusters[0].Name.Should().Be("platform-cache"); - clusters[0].Status.Should().Be(RedisClusterStatus.Provisioning); - - redisClient.Verify(c => c.CreateClusterAsync( - "apiVersion: v1\nkind: Config", - "prod-ctx", - It.Is(s => - s.Name == "platform-cache" && - s.RedisVersion == "7.2.5" && - s.Replicas == 3 && - s.SentinelEnabled == true), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task CreateCluster_WithMissingName_ReturnsFailure() - { - // Arrange - - CreateRedisClusterHandler handler = new(repository, redisClient.Object); - - CreateRedisClusterRequest request = new( - EnvironmentId: Guid.NewGuid(), - ClusterId: Guid.NewGuid(), - Name: "", - Namespace: "redis", - RedisVersion: "7.2.5", - Replicas: 3, - StorageSize: "10Gi", - SentinelEnabled: true, - KubeConfig: "config", - ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert — Domain validation rejects empty names. - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("name"); - } - - // ─── Adopt Clusters ─────────────────────────────────────────────────────── - - [Fact] - public async Task AdoptClusters_DiscoversTwoInstances_CreatesBothInRunningState() - { - // Arrange — The K8s cluster has two existing Redis instances. - - redisClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("session-cache", "redis", "7.2.5", 3, "10Gi", true), - new("rate-limiter", "redis", "7.0.15", 1, "5Gi", false) - }); - - AdoptRedisClustersHandler handler = new(repository, redisClient.Object); - - AdoptRedisClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Both clusters adopted. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(2); - - IReadOnlyList clusters = await repository.GetAllAsync(); - clusters.Should().HaveCount(2); - clusters.Should().OnlyContain(c => c.Status == RedisClusterStatus.Running); - } - - [Fact] - public async Task AdoptClusters_AlreadyAdopted_ReturnsExistingIdWithoutDuplicate() - { - // Arrange — One instance already adopted. - - Guid clusterId = Guid.NewGuid(); - - RedisCluster existing = RedisCluster.Adopt( - Guid.NewGuid(), clusterId, "session-cache", "redis", "7.2.5", 3, "10Gi", true); - await repository.AddAsync(existing); - - redisClient.Setup(c => c.DiscoverClustersAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List - { - new("session-cache", "redis", "7.2.5", 3, "10Gi", true) - }); - - AdoptRedisClustersHandler handler = new(repository, redisClient.Object); - - AdoptRedisClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx"); - - // Act - - Result> result = await handler.HandleAsync(request); - - // Assert — Returns existing ID without creating a duplicate. - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().HaveCount(1); - result.Value![0].Should().Be(existing.Id); - - IReadOnlyList clusters = await repository.GetAllAsync(); - clusters.Should().HaveCount(1); - } - - // ─── Upgrade ────────────────────────────────────────────────────────────── - - [Fact] - public async Task UpgradeCluster_WithValidVersion_TransitionsToUpgrading() - { - // Arrange — A running Redis instance at version 7.0.15. - - RedisCluster redisCluster = RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true); - redisCluster.MarkRunning(); - await repository.AddAsync(redisCluster); - - redisClient.Setup(c => c.GetAvailableVersionsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { "7.0.15", "7.2.5" }); - - UpgradeRedisClusterHandler handler = new(repository, redisClient.Object); - - UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - redisCluster.Status.Should().Be(RedisClusterStatus.Upgrading); - redisCluster.TargetVersion.Should().Be("7.2.5"); - - redisClient.Verify(c => c.UpgradeClusterAsync( - "config", "ctx", "cache", "redis", "7.2.5", - It.IsAny()), Times.Once); - } - - [Fact] - public async Task UpgradeCluster_UnavailableVersion_ReturnsFailure() - { - // Arrange — Target version not in the available list. - - RedisCluster redisCluster = RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true); - redisCluster.MarkRunning(); - await repository.AddAsync(redisCluster); - - redisClient.Setup(c => c.GetAvailableVersionsAsync( - It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new List { "7.0.15" }); - - UpgradeRedisClusterHandler handler = new(repository, redisClient.Object); - - UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not available"); - } - - // ─── Configure ──────────────────────────────────────────────────────────── - - [Fact] - public async Task ConfigureCluster_ScaleReplicas_UpdatesAndCallsK8s() - { - // Arrange — A running 3-replica cluster to be scaled to 5. - - RedisCluster redisCluster = RedisCluster.Create( - Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.2.5", 3, "10Gi", true); - redisCluster.MarkRunning(); - await repository.AddAsync(redisCluster); - - ConfigureRedisClusterHandler handler = new(repository, redisClient.Object); - - ConfigureRedisClusterRequest request = new( - redisCluster.Id, Replicas: 5, StorageSize: null, SentinelEnabled: null, - KubeConfig: "config", ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsSuccess.Should().BeTrue(); - redisCluster.Replicas.Should().Be(5); - - redisClient.Verify(c => c.UpdateClusterAsync( - "config", "ctx", - It.Is(s => s.Replicas == 5), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task ConfigureCluster_NonExistent_ReturnsFailure() - { - // Arrange — No cluster with this ID. - - ConfigureRedisClusterHandler handler = new(repository, redisClient.Object); - - ConfigureRedisClusterRequest request = new( - Guid.NewGuid(), Replicas: 5, StorageSize: null, SentinelEnabled: null, - KubeConfig: "config", ContextName: "ctx"); - - // Act - - Result result = await handler.HandleAsync(request); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/RemoveAppEnvironmentHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/RemoveAppEnvironmentHandlerTests.cs deleted file mode 100644 index e8e750a..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/RemoveAppEnvironmentHandlerTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.RemoveAppEnvironment; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class RemoveAppEnvironmentHandlerTests -{ - private readonly InMemoryAppRepository repository; - private readonly RemoveAppEnvironmentHandler handler; - - public RemoveAppEnvironmentHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new RemoveAppEnvironmentHandler(repository); - } - - [Fact] - public async Task HandleAsync_ExistingEnvironment_RemovesSuccessfully() - { - // Arrange — An app with one environment. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - app.AddEnvironment(envId, Guid.NewGuid(), "api-dev"); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, envId)); - - // Assert — The environment should be removed. - - result.IsSuccess.Should().BeTrue(); - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments.Should().BeEmpty(); - } - - [Fact] - public async Task HandleAsync_NonexistentApp_ReturnsFailure() - { - // Act - - Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(Guid.NewGuid(), Guid.NewGuid())); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_NonexistentEnvironment_ReturnsFailure() - { - // Arrange — An app with no environments. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, Guid.NewGuid())); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/RemoveAppSecretHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/RemoveAppSecretHandlerTests.cs deleted file mode 100644 index f1a93ce..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/RemoveAppSecretHandlerTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.RemoveAppSecret; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class RemoveAppSecretHandlerTests -{ - private readonly InMemoryAppRepository repository; - private readonly RemoveAppSecretHandler handler; - - public RemoveAppSecretHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new RemoveAppSecretHandler(repository); - } - - [Fact] - public async Task HandleAsync_ExistingSecret_RemovesSuccessfully() - { - // Arrange — An app with an environment that has a secret. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev"); - env.AddSecret("DATABASE_URL", "db-conn"); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync( - new RemoveAppSecretRequest(app.Id, envId, "DATABASE_URL")); - - // Assert - - result.IsSuccess.Should().BeTrue(); - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments[0].Secrets.Should().BeEmpty(); - } - - [Fact] - public async Task HandleAsync_NonexistentApp_ReturnsFailure() - { - // Act - - Result result = await handler.HandleAsync( - new RemoveAppSecretRequest(Guid.NewGuid(), Guid.NewGuid(), "KEY")); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } - - [Fact] - public async Task HandleAsync_NonexistentSecret_ReturnsFailure() - { - // Arrange - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - app.AddEnvironment(envId, Guid.NewGuid(), "api-dev"); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync( - new RemoveAppSecretRequest(app.Id, envId, "MISSING")); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/SuspendActivateAppHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/SuspendActivateAppHandlerTests.cs deleted file mode 100644 index bac41ee..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/SuspendActivateAppHandlerTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.SuspendActivateApp; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class SuspendActivateAppHandlerTests -{ - private readonly InMemoryAppRepository repository; - private readonly SuspendActivateAppHandler handler; - - public SuspendActivateAppHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new SuspendActivateAppHandler(repository); - } - - [Fact] - public async Task SuspendAsync_ActiveApp_SuspendsSuccessfully() - { - // Arrange — An active app. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - await repository.AddAsync(app); - - // Act - - Result result = await handler.SuspendAsync(app.Id); - - // Assert - - result.IsSuccess.Should().BeTrue(); - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Status.Should().Be(AppStatus.Suspended); - } - - [Fact] - public async Task ActivateAsync_SuspendedApp_ActivatesSuccessfully() - { - // Arrange — A suspended app. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - app.Suspend(); - await repository.AddAsync(app); - - // Act - - Result result = await handler.ActivateAsync(app.Id); - - // Assert - - result.IsSuccess.Should().BeTrue(); - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Status.Should().Be(AppStatus.Active); - } - - [Fact] - public async Task SuspendAsync_NonexistentApp_ReturnsFailure() - { - // Act - - Result result = await handler.SuspendAsync(Guid.NewGuid()); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Provisioning.Tests/Features/TriggerSyncHandlerTests.cs b/tests/EntKube.Provisioning.Tests/Features/TriggerSyncHandlerTests.cs deleted file mode 100644 index d828c89..0000000 --- a/tests/EntKube.Provisioning.Tests/Features/TriggerSyncHandlerTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -using EntKube.Provisioning.Domain; -using EntKube.Provisioning.Features.Apps.TriggerSync; -using EntKube.Provisioning.Infrastructure; -using EntKube.SharedKernel.Domain; -using FluentAssertions; - -namespace EntKube.Provisioning.Tests.Features; - -public class TriggerSyncHandlerTests -{ - private readonly InMemoryAppRepository repository; - private readonly TriggerSyncHandler handler; - - public TriggerSyncHandlerTests() - { - repository = new InMemoryAppRepository(); - handler = new TriggerSyncHandler(repository); - } - - [Fact] - public async Task HandleAsync_SyncedEnvironment_MarksPending() - { - // Arrange — An environment that was previously synced. The user - // wants to force a re-sync (e.g., after a config change outside - // the UI or to retry after fixing a problem). - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "api", Tag: "v1", Replicas: 1, - ContainerPort: 8080, ServicePort: 80, - HostName: null, PathPrefix: null, - EnvironmentVariables: null, Resources: null)); - - env.MarkSynced(); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId)); - - // Assert — Should be marked Pending so the reconciler picks it up. - - result.IsSuccess.Should().BeTrue(); - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending); - } - - [Fact] - public async Task HandleAsync_ErrorEnvironment_MarksPendingForRetry() - { - // Arrange — An environment in error state. The admin fixes the issue - // and wants to retry the sync. - - App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment); - Guid envId = Guid.NewGuid(); - AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev"); - - env.ConfigureDeployment(new DeploymentSpec( - Image: "api", Tag: "v1", Replicas: 1, - ContainerPort: 8080, ServicePort: 80, - HostName: null, PathPrefix: null, - EnvironmentVariables: null, Resources: null)); - - env.MarkError("Connection refused"); - await repository.AddAsync(app); - - // Act - - Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId)); - - // Assert - - result.IsSuccess.Should().BeTrue(); - App? updated = await repository.GetByIdAsync(app.Id); - updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending); - } - - [Fact] - public async Task HandleAsync_NonexistentApp_ReturnsFailure() - { - // Act - - Result result = await handler.HandleAsync( - new TriggerSyncRequest(Guid.NewGuid(), Guid.NewGuid())); - - // Assert - - result.IsFailure.Should().BeTrue(); - result.Error.Should().Contain("not found"); - } -} diff --git a/tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs b/tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs deleted file mode 100644 index 6975de1..0000000 --- a/tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Text; -using EntKube.Secrets.Crypto; -using FluentAssertions; - -namespace EntKube.Secrets.Tests.Crypto; - -/// -/// Tests for the AES-256-GCM encryption primitive. This is the foundation of -/// the entire secrets manager — every secret is encrypted with AES-256-GCM -/// before being stored. We verify that encryption produces ciphertext that -/// differs from plaintext, that decryption recovers the original data, and -/// that tampering with any part of the ciphertext is detected. -/// -public class AesGcmEncryptorTests -{ - [Fact] - public void Encrypt_ThenDecrypt_RecoversOriginalPlaintext() - { - // Arrange — A 256-bit key and some plaintext to protect. - // This is the most basic contract: encrypt then decrypt = identity. - - byte[] key = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("dns-solver-api-token-abc123"); - - // Act — Encrypt the plaintext, then decrypt the result. - - byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext); - byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext); - - // Assert — The recovered plaintext must match the original exactly. - - recovered.Should().BeEquivalentTo(plaintext); - } - - [Fact] - public void Encrypt_ProducesDifferentCiphertextEachTime() - { - // Arrange — Same key and same plaintext encrypted twice. - // AES-GCM uses a random nonce, so each encryption should produce - // different ciphertext even for identical inputs. - - byte[] key = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("same-secret-value"); - - // Act - - byte[] ciphertext1 = AesGcmEncryptor.Encrypt(key, plaintext); - byte[] ciphertext2 = AesGcmEncryptor.Encrypt(key, plaintext); - - // Assert — Ciphertexts must differ (different nonces). - - ciphertext1.Should().NotBeEquivalentTo(ciphertext2); - } - - [Fact] - public void Encrypt_CiphertextDiffersFromPlaintext() - { - // Arrange - - byte[] key = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("my-super-secret-value"); - - // Act - - byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext); - - // Assert — The ciphertext must not contain the plaintext verbatim. - - string ciphertextStr = Encoding.UTF8.GetString(ciphertext); - ciphertextStr.Should().NotContain("my-super-secret-value"); - } - - [Fact] - public void Decrypt_WithWrongKey_ThrowsCryptographicException() - { - // Arrange — Encrypt with one key, try to decrypt with a different key. - // AES-GCM's authentication tag ensures this fails loudly. - - byte[] correctKey = AesGcmEncryptor.GenerateKey(); - byte[] wrongKey = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("secret-data"); - byte[] ciphertext = AesGcmEncryptor.Encrypt(correctKey, plaintext); - - // Act & Assert — Decryption with the wrong key must fail. - - Action act = () => AesGcmEncryptor.Decrypt(wrongKey, ciphertext); - act.Should().Throw(); - } - - [Fact] - public void Decrypt_WithTamperedCiphertext_ThrowsCryptographicException() - { - // Arrange — Encrypt normally, then flip a bit in the ciphertext. - // AES-GCM's authenticated encryption detects any tampering. - - byte[] key = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("integrity-protected-data"); - byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext); - - // Tamper with a byte in the encrypted portion (after the nonce). - - ciphertext[15] ^= 0xFF; - - // Act & Assert — Tampered data must be rejected. - - Action act = () => AesGcmEncryptor.Decrypt(key, ciphertext); - act.Should().Throw(); - } - - [Fact] - public void GenerateKey_Returns32Bytes() - { - // AES-256 requires a 256-bit (32-byte) key. - - byte[] key = AesGcmEncryptor.GenerateKey(); - - key.Should().HaveCount(32); - } - - [Fact] - public void GenerateKey_ProducesUniqueKeys() - { - // Two generated keys must never be the same (CSPRNG guarantee). - - byte[] key1 = AesGcmEncryptor.GenerateKey(); - byte[] key2 = AesGcmEncryptor.GenerateKey(); - - key1.Should().NotBeEquivalentTo(key2); - } - - [Fact] - public void Encrypt_WithEmptyPlaintext_WorksCorrectly() - { - // Edge case — encrypting an empty byte array should still work. - // Some secrets might be empty during initialization. - - byte[] key = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Array.Empty(); - - // Act - - byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext); - byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext); - - // Assert - - recovered.Should().BeEmpty(); - } - - [Fact] - public void Encrypt_WithLargePayload_WorksCorrectly() - { - // Secrets can be large (e.g., PEM certificates, JSON blobs). - // Verify AES-GCM handles payloads beyond typical small secrets. - - byte[] key = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = new byte[64 * 1024]; // 64KB - Random.Shared.NextBytes(plaintext); - - // Act - - byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext); - byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext); - - // Assert - - recovered.Should().BeEquivalentTo(plaintext); - } -} diff --git a/tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs b/tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs deleted file mode 100644 index 3e62bfe..0000000 --- a/tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Text; -using EntKube.Secrets.Crypto; -using FluentAssertions; - -namespace EntKube.Secrets.Tests.Crypto; - -/// -/// Tests for the envelope encryption engine. Envelope encryption is the core -/// pattern: each secret gets its own Data Encryption Key (DEK), the DEK encrypts -/// the secret, and the Master Encryption Key (MEK) encrypts the DEK. This way: -/// - Rotating the MEK only requires re-encrypting DEKs (not all secrets) -/// - Compromising one DEK only exposes one secret -/// - The MEK never directly touches secret data -/// -public class EnvelopeEncryptionTests -{ - [Fact] - public void Seal_ThenOpen_RecoversOriginalPlaintext() - { - // Arrange — A master key and some secret data to protect. - // "Seal" wraps the data in an envelope (generates DEK, encrypts data, - // encrypts DEK). "Open" reverses the process. - - byte[] masterKey = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("azure-dns-client-secret-value"); - - // Act - - EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext); - byte[] recovered = EnvelopeEncryption.Open(masterKey, envelope); - - // Assert - - recovered.Should().BeEquivalentTo(plaintext); - } - - [Fact] - public void Seal_ProducesDifferentEnvelopesForSameData() - { - // Arrange — Each seal operation generates a new random DEK, so - // two envelopes for the same data should be completely different. - - byte[] masterKey = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("same-secret"); - - // Act - - EncryptedEnvelope envelope1 = EnvelopeEncryption.Seal(masterKey, plaintext); - EncryptedEnvelope envelope2 = EnvelopeEncryption.Seal(masterKey, plaintext); - - // Assert — Both the encrypted DEK and encrypted data should differ. - - envelope1.EncryptedDek.Should().NotBeEquivalentTo(envelope2.EncryptedDek); - envelope1.EncryptedData.Should().NotBeEquivalentTo(envelope2.EncryptedData); - } - - [Fact] - public void Open_WithWrongMasterKey_Fails() - { - // Arrange — Seal with one MEK, try to open with another. - // The wrong MEK can't decrypt the DEK, so decryption fails. - - byte[] correctMek = AesGcmEncryptor.GenerateKey(); - byte[] wrongMek = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("secret-data"); - - EncryptedEnvelope envelope = EnvelopeEncryption.Seal(correctMek, plaintext); - - // Act & Assert - - Action act = () => EnvelopeEncryption.Open(wrongMek, envelope); - act.Should().Throw(); - } - - [Fact] - public void Open_WithTamperedEncryptedDek_Fails() - { - // Arrange — Tamper with the encrypted DEK. - - byte[] masterKey = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("integrity-check"); - - EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext); - - // Tamper with the encrypted DEK. - - byte[] tamperedDek = (byte[])envelope.EncryptedDek.Clone(); - tamperedDek[10] ^= 0xFF; - EncryptedEnvelope tampered = new(tamperedDek, envelope.EncryptedData); - - // Act & Assert - - Action act = () => EnvelopeEncryption.Open(masterKey, tampered); - act.Should().Throw(); - } - - [Fact] - public void Open_WithTamperedEncryptedData_Fails() - { - // Arrange — Tamper with the encrypted data payload. - - byte[] masterKey = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("tamper-detection"); - - EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext); - - byte[] tamperedData = (byte[])envelope.EncryptedData.Clone(); - tamperedData[15] ^= 0xFF; - EncryptedEnvelope tampered = new(envelope.EncryptedDek, tamperedData); - - // Act & Assert - - Action act = () => EnvelopeEncryption.Open(masterKey, tampered); - act.Should().Throw(); - } - - [Fact] - public void ReWrap_ChangesEncryptedDekButPreservesData() - { - // Arrange — Re-wrapping is used during master key rotation. The secret - // data stays the same but the DEK is re-encrypted with the new MEK. - - byte[] oldMek = AesGcmEncryptor.GenerateKey(); - byte[] newMek = AesGcmEncryptor.GenerateKey(); - byte[] plaintext = Encoding.UTF8.GetBytes("rewrap-test-secret"); - - EncryptedEnvelope original = EnvelopeEncryption.Seal(oldMek, plaintext); - - // Act — Re-wrap: decrypt the DEK with old MEK, re-encrypt with new MEK. - - EncryptedEnvelope rewrapped = EnvelopeEncryption.ReWrap(oldMek, newMek, original); - - // Assert — The rewrapped envelope opens with the new MEK. - - byte[] recovered = EnvelopeEncryption.Open(newMek, rewrapped); - recovered.Should().BeEquivalentTo(plaintext); - - // Assert — The old MEK no longer works. - - Action act = () => EnvelopeEncryption.Open(oldMek, rewrapped); - act.Should().Throw(); - } -} diff --git a/tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs b/tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs deleted file mode 100644 index 8d3e027..0000000 --- a/tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using EntKube.Secrets.Crypto; -using FluentAssertions; - -namespace EntKube.Secrets.Tests.Crypto; - -/// -/// Tests for Shamir's Secret Sharing scheme. This is the mechanism that protects -/// the master encryption key — the key is split into N shares, and any M of those -/// shares can reconstruct it. Fewer than M shares reveal nothing about the key. -/// -/// This is critical for the vault's seal/unseal lifecycle: on initialization the -/// master key is split into shares distributed to key holders. To unseal the vault -/// after a restart, M key holders must each provide their share. -/// -public class ShamirSecretSharingTests -{ - [Fact] - public void Split_ThenCombine_WithExactThreshold_RecoversSecret() - { - // Arrange — A 256-bit master key, split into 5 shares with a threshold of 3. - // Any 3 shares should be enough to recover the original key. - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - // Act — Split into 5 shares, then recombine using exactly 3. - - List shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3); - byte[] recovered = ShamirSecretSharing.Combine(shares.Take(3).ToList(), threshold: 3); - - // Assert — The recovered secret must match the original exactly. - - recovered.Should().BeEquivalentTo(secret); - } - - [Fact] - public void Split_ThenCombine_WithMoreThanThreshold_RecoversSecret() - { - // Arrange — Using more shares than required should also work. - // This tests that extra shares don't corrupt the reconstruction. - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - // Act — Split into 5, recombine using all 5 (threshold is still 3). - - List shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3); - byte[] recovered = ShamirSecretSharing.Combine(shares, threshold: 3); - - // Assert - - recovered.Should().BeEquivalentTo(secret); - } - - [Fact] - public void Split_ThenCombine_WithDifferentShareSubsets_AllRecoverSecret() - { - // Arrange — Any combination of M-of-N shares should work, not just - // the first M. This tests that shares 2,3,5 work as well as 1,2,3. - - byte[] secret = AesGcmEncryptor.GenerateKey(); - List shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3); - - // Act & Assert — Multiple subsets of 3 shares all recover the secret. - - ShamirSecretSharing.Combine(new List { shares[0], shares[1], shares[2] }, 3) - .Should().BeEquivalentTo(secret, "shares 1,2,3 should recover the secret"); - - ShamirSecretSharing.Combine(new List { shares[0], shares[2], shares[4] }, 3) - .Should().BeEquivalentTo(secret, "shares 1,3,5 should recover the secret"); - - ShamirSecretSharing.Combine(new List { shares[1], shares[3], shares[4] }, 3) - .Should().BeEquivalentTo(secret, "shares 2,4,5 should recover the secret"); - } - - [Fact] - public void Split_ProducesCorrectNumberOfShares() - { - // Arrange - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - // Act - - List shares = ShamirSecretSharing.Split(secret, totalShares: 7, threshold: 4); - - // Assert — Must produce exactly the requested number of shares. - - shares.Should().HaveCount(7); - } - - [Fact] - public void Split_EachShareHasUniqueIndex() - { - // Arrange - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - // Act - - List shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3); - - // Assert — Share indices must be unique (used as x-coordinates in Lagrange interpolation). - - List indices = shares.Select(s => s.Index).ToList(); - indices.Should().OnlyHaveUniqueItems(); - } - - [Fact] - public void Split_WithThresholdOf1_EachShareIsTheSecret() - { - // Arrange — Threshold of 1 means any single share recovers the secret. - // This is the degenerate case (no split protection). - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - // Act - - List shares = ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 1); - - // Assert — Each individual share should recover the secret. - - foreach (ShamirShare share in shares) - { - byte[] recovered = ShamirSecretSharing.Combine(new List { share }, threshold: 1); - recovered.Should().BeEquivalentTo(secret); - } - } - - [Fact] - public void Split_WithThresholdEqualsTotal_RequiresAllShares() - { - // Arrange — 3-of-3 means all shares are needed. - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - // Act - - List shares = ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 3); - byte[] recovered = ShamirSecretSharing.Combine(shares, threshold: 3); - - // Assert - - recovered.Should().BeEquivalentTo(secret); - } - - [Fact] - public void Split_WithInvalidParameters_Throws() - { - // Threshold must be >= 1, total must be >= threshold. - - byte[] secret = AesGcmEncryptor.GenerateKey(); - - Action zeroThreshold = () => ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 0); - zeroThreshold.Should().Throw(); - - Action thresholdExceedsTotal = () => ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 5); - thresholdExceedsTotal.Should().Throw(); - } - - [Fact] - public void Combine_WithTooFewShares_ProducesWrongResult() - { - // Arrange — With fewer than threshold shares, Lagrange interpolation - // produces a different polynomial, so the result should not match. - // This is the information-theoretic security guarantee of Shamir's scheme. - - byte[] secret = AesGcmEncryptor.GenerateKey(); - List shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3); - - // Act — Try to combine only 2 shares when 3 are needed. - - byte[] wrongResult = ShamirSecretSharing.Combine(shares.Take(2).ToList(), threshold: 2); - - // Assert — The result must NOT equal the original secret. - - wrongResult.Should().NotBeEquivalentTo(secret); - } - - [Fact] - public void Split_ThenCombine_WorksWithDifferentSecretSizes() - { - // Shamir's scheme works byte-by-byte, so it should handle any size secret. - - byte[] smallSecret = new byte[] { 0x42 }; - byte[] largeSecret = new byte[64]; // 512-bit key - Random.Shared.NextBytes(largeSecret); - - List smallShares = ShamirSecretSharing.Split(smallSecret, 3, 2); - List largeShares = ShamirSecretSharing.Split(largeSecret, 3, 2); - - ShamirSecretSharing.Combine(smallShares.Take(2).ToList(), 2) - .Should().BeEquivalentTo(smallSecret); - - ShamirSecretSharing.Combine(largeShares.Take(2).ToList(), 2) - .Should().BeEquivalentTo(largeSecret); - } -} diff --git a/tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs b/tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs deleted file mode 100644 index d772ee5..0000000 --- a/tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs +++ /dev/null @@ -1,232 +0,0 @@ -using EntKube.Secrets.Domain; -using FluentAssertions; - -namespace EntKube.Secrets.Tests.Domain; - -public class SecretScopeTests -{ - // ─── Factory Method Tests ──────────────────────────────────────────── - - [Fact] - public void ForCluster_CreatesInfrastructureScope() - { - // Arrange — Infrastructure secrets are scoped to Tenant + Environment + Cluster. - // These are things like kubeconfig credentials, DNS tokens, cloud provider secrets. - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - - // Act - - SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId); - - // Assert - - scope.TenantId.Should().Be(tenantId); - scope.EnvironmentId.Should().Be(environmentId); - scope.ClusterId.Should().Be(clusterId); - scope.CustomerId.Should().BeNull(); - scope.AppName.Should().BeNull(); - } - - [Fact] - public void ForApp_CreatesApplicationScope() - { - // Arrange — Application secrets are scoped to Tenant + Customer + App + Environment. - // These are things like database connection strings, API keys for third-party services. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - - // Act - - SecretScope scope = SecretScope.ForApp(tenantId, customerId, "my-api", environmentId); - - // Assert - - scope.TenantId.Should().Be(tenantId); - scope.EnvironmentId.Should().Be(environmentId); - scope.ClusterId.Should().BeNull(); - scope.CustomerId.Should().Be(customerId); - scope.AppName.Should().Be("my-api"); - } - - // ─── Record Construction Tests ─────────────────────────────────────── - - [Fact] - public void Create_WithAllFields_StoresAllValues() - { - // Arrange — A fully-scoped secret with all fields populated. - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - - // Act - - SecretScope scope = new(tenantId, environmentId, clusterId, customerId, "my-api"); - - // Assert - - scope.TenantId.Should().Be(tenantId); - scope.EnvironmentId.Should().Be(environmentId); - scope.ClusterId.Should().Be(clusterId); - scope.CustomerId.Should().Be(customerId); - scope.AppName.Should().Be("my-api"); - } - - [Fact] - public void Create_WithTenantOnly_AllowsNullOptionalFields() - { - // Arrange & Act — A tenant-wide secret (e.g. a shared API key) - // that isn't scoped to a specific environment, cluster, customer, or app. - - Guid tenantId = Guid.NewGuid(); - SecretScope scope = new(tenantId, null, null, null, null); - - // Assert - - scope.TenantId.Should().Be(tenantId); - scope.EnvironmentId.Should().BeNull(); - scope.ClusterId.Should().BeNull(); - scope.CustomerId.Should().BeNull(); - scope.AppName.Should().BeNull(); - } - - [Fact] - public void Create_WithTenantAndEnvironment_ScopesToEnvironment() - { - // Arrange & Act — A secret scoped to a tenant + environment - // (e.g. a dev database password shared across all customers in dev). - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - SecretScope scope = new(tenantId, environmentId, null, null, null); - - // Assert - - scope.TenantId.Should().Be(tenantId); - scope.EnvironmentId.Should().Be(environmentId); - scope.ClusterId.Should().BeNull(); - scope.CustomerId.Should().BeNull(); - scope.AppName.Should().BeNull(); - } - - // ─── BuildPath Tests ───────────────────────────────────────────────── - - [Fact] - public void BuildPath_TenantOnly_ReturnsPathWithTenantPrefix() - { - // Arrange — A tenant-scoped secret should have a path like - // "tenants/{tenantId}/secrets/my-key". - - Guid tenantId = Guid.NewGuid(); - SecretScope scope = new(tenantId, null, null, null, null); - - // Act - - string path = scope.BuildPath("my-key"); - - // Assert - - path.Should().Be($"tenants/{tenantId}/secrets/my-key"); - } - - [Fact] - public void BuildPath_TenantAndEnvironment_ReturnsEnvironmentScopedPath() - { - // Arrange - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - SecretScope scope = new(tenantId, environmentId, null, null, null); - - // Act - - string path = scope.BuildPath("db-password"); - - // Assert - - path.Should().Be($"tenants/{tenantId}/environments/{environmentId}/secrets/db-password"); - } - - [Fact] - public void BuildPath_ClusterScope_ReturnsClusterScopedPath() - { - // Arrange — Infrastructure secret scoped to a specific cluster. - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - Guid clusterId = Guid.NewGuid(); - SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId); - - // Act - - string path = scope.BuildPath("dns-credential"); - - // Assert — Path includes the cluster segment. - - path.Should().Be( - $"tenants/{tenantId}/environments/{environmentId}/clusters/{clusterId}/secrets/dns-credential"); - } - - [Fact] - public void BuildPath_AppScope_ReturnsFullAppScopedPath() - { - // Arrange — Application secret: tenant → environment → customer → app. - - Guid tenantId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - SecretScope scope = SecretScope.ForApp(tenantId, customerId, "extract01", environmentId); - - // Act - - string path = scope.BuildPath("api-token"); - - // Assert - - path.Should().Be( - $"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/apps/extract01/secrets/api-token"); - } - - [Fact] - public void BuildPath_CustomerWithoutApp_IncludesCustomerScope() - { - // Arrange — Customer-scoped without a specific app. - - Guid tenantId = Guid.NewGuid(); - Guid environmentId = Guid.NewGuid(); - Guid customerId = Guid.NewGuid(); - SecretScope scope = new(tenantId, environmentId, null, customerId, null); - - // Act - - string path = scope.BuildPath("shared-token"); - - // Assert - - path.Should().Be( - $"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/secrets/shared-token"); - } - - // ─── Equality ──────────────────────────────────────────────────────── - - [Fact] - public void Equality_SameValues_AreEqual() - { - // Arrange — Records should have value-based equality. - - Guid tenantId = Guid.NewGuid(); - Guid envId = Guid.NewGuid(); - SecretScope scope1 = new(tenantId, envId, null, null, null); - SecretScope scope2 = new(tenantId, envId, null, null, null); - - // Assert - - scope1.Should().Be(scope2); - } -} diff --git a/tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs b/tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs deleted file mode 100644 index 7e95a41..0000000 --- a/tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using EntKube.Secrets.Crypto; -using EntKube.Secrets.Domain; -using EntKube.Secrets.Infrastructure; -using FluentAssertions; - -namespace EntKube.Secrets.Tests.Domain; - -/// -/// Tests for service token authentication. Other EntKube services authenticate -/// to the secrets service using service tokens scoped to specific path prefixes -/// with specific permissions. Tokens are now tenant-scoped. -/// -public class ServiceTokenTests -{ - private readonly InMemoryVaultRepository repository = new(); - private readonly byte[] kek = AesGcmEncryptor.GenerateKey(); - private readonly Guid tenantId = Guid.NewGuid(); - - private Vault CreateVault() => new(repository); - - [Fact] - public async Task CreateToken_ReturnsTokenWithCorrectPolicies() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - ServiceTokenResult token = await vault.CreateServiceTokenAsync( - tenantId, - name: "clusters-service", - policies: new List - { - new("clusters/", AccessOperation.Read | AccessOperation.Write | AccessOperation.List), - new("shared/certificates/", AccessOperation.Read) - }); - - token.Token.Should().NotBeNullOrEmpty(); - token.Name.Should().Be("clusters-service"); - token.Policies.Should().HaveCount(2); - } - - [Fact] - public async Task ValidateToken_WithValidToken_ReturnsTrue() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - ServiceTokenResult token = await vault.CreateServiceTokenAsync( - tenantId, - name: "test-service", - policies: new List - { - new("test/", AccessOperation.Read) - }); - - TokenValidationResult result = await vault.ValidateTokenAsync(token.Token); - - result.IsValid.Should().BeTrue(); - result.Name.Should().Be("test-service"); - } - - [Fact] - public async Task ValidateToken_WithInvalidToken_ReturnsFalse() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - TokenValidationResult result = await vault.ValidateTokenAsync("invalid-token-that-was-never-issued"); - - result.IsValid.Should().BeFalse(); - } - - [Fact] - public async Task CheckAccess_WithMatchingPolicy_AllowsOperation() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - ServiceTokenResult token = await vault.CreateServiceTokenAsync( - tenantId, - name: "clusters-service", - policies: new List - { - new("clusters/", AccessOperation.Read | AccessOperation.Write) - }); - - bool canRead = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Read); - bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Write); - - canRead.Should().BeTrue(); - canWrite.Should().BeTrue(); - } - - [Fact] - public async Task CheckAccess_WithNoMatchingPolicy_DeniesOperation() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - ServiceTokenResult token = await vault.CreateServiceTokenAsync( - tenantId, - name: "readonly-service", - policies: new List - { - new("clusters/", AccessOperation.Read) - }); - - bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/secret", AccessOperation.Write); - bool canReadOther = await vault.CheckAccessAsync(token.Token, "identity/users/", AccessOperation.Read); - - canWrite.Should().BeFalse(); - canReadOther.Should().BeFalse(); - } - - [Fact] - public async Task RevokeToken_PreventsSubsequentValidation() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - ServiceTokenResult token = await vault.CreateServiceTokenAsync( - tenantId, - name: "temporary-service", - policies: new List - { - new("temp/", AccessOperation.Read) - }); - - TokenValidationResult validBefore = await vault.ValidateTokenAsync(token.Token); - validBefore.IsValid.Should().BeTrue(); - - await vault.RevokeTokenAsync(token.Token); - - TokenValidationResult validAfter = await vault.ValidateTokenAsync(token.Token); - validAfter.IsValid.Should().BeFalse(); - } - - [Fact] - public async Task ListTokens_ReturnsActiveTokenMetadata() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.CreateServiceTokenAsync(tenantId, "service-a", new List - { - new("a/", AccessOperation.Read) - }); - - await vault.CreateServiceTokenAsync(tenantId, "service-b", new List - { - new("b/", AccessOperation.Read | AccessOperation.Write) - }); - - List tokens = await vault.ListTokensAsync(tenantId); - - tokens.Should().HaveCount(2); - tokens.Should().Contain(t => t.Name == "service-a"); - tokens.Should().Contain(t => t.Name == "service-b"); - } - - [Fact] - public async Task MultiInstance_TokenCreatedOnOnePodValidatesOnAnother() - { - Vault pod1 = CreateVault(); - await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - Vault pod2 = CreateVault(); - await pod2.AutoUnsealAsync(kek); - - ServiceTokenResult token = await pod1.CreateServiceTokenAsync( - tenantId, - name: "cross-pod-service", - policies: new List { new("shared/", AccessOperation.Read) }); - - TokenValidationResult result = await pod2.ValidateTokenAsync(token.Token); - result.IsValid.Should().BeTrue(); - result.Name.Should().Be("cross-pod-service"); - } -} diff --git a/tests/EntKube.Secrets.Tests/Domain/VaultTests.cs b/tests/EntKube.Secrets.Tests/Domain/VaultTests.cs deleted file mode 100644 index cc6c201..0000000 --- a/tests/EntKube.Secrets.Tests/Domain/VaultTests.cs +++ /dev/null @@ -1,518 +0,0 @@ -using EntKube.Secrets.Crypto; -using EntKube.Secrets.Domain; -using EntKube.Secrets.Infrastructure; -using FluentAssertions; - -namespace EntKube.Secrets.Tests.Domain; - -/// -/// Tests for the multi-tenant Vault aggregate. Each tenant gets their own -/// master encryption key (MEK) for cryptographic isolation. -/// -/// The vault manages per-tenant MEKs — initialization, auto-unseal, and -/// secret operations are all scoped to a specific tenant. -/// -public class VaultTests -{ - private readonly InMemoryVaultRepository repository = new(); - private readonly byte[] kek = AesGcmEncryptor.GenerateKey(); - private readonly Guid tenantId = Guid.NewGuid(); - - private Vault CreateVault() => new(repository); - - // ─── Initialization ────────────────────────────────────────────────── - - [Fact] - public async Task Initialize_GeneratesMasterKeyAndAutoUnseals() - { - // Arrange — A fresh vault backed by an empty repository. - - Vault vault = CreateVault(); - - // Act — Initialize a tenant with KEK, 5 Shamir shares, threshold of 3. - - InitializationResult result = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3); - - // Assert — Shares returned for DR, tenant's vault is immediately unsealed. - - result.Shares.Should().HaveCount(5); - result.Threshold.Should().Be(3); - vault.IsTenantInitialized(tenantId).Should().BeTrue(); - vault.IsTenantSealed(tenantId).Should().BeFalse(); - } - - [Fact] - public async Task Initialize_WhenAlreadyInitialized_Fails() - { - // A tenant's vault can only be initialized once. - - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); - - Func act = () => vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); - await act.Should().ThrowAsync() - .WithMessage("*already initialized*"); - } - - // ─── Auto-Unseal (Normal K8s Startup) ──────────────────────────────── - - [Fact] - public async Task AutoUnseal_WithCorrectKek_UnsealsAllTenants() - { - // Arrange — Initialize two tenants, then seal both. - - Guid tenant1 = Guid.NewGuid(); - Guid tenant2 = Guid.NewGuid(); - Vault vault = CreateVault(); - await vault.InitializeAsync(tenant1, kek, totalShares: 1, threshold: 1); - await vault.InitializeAsync(tenant2, kek, totalShares: 1, threshold: 1); - await vault.SealAsync(); - - // Act — A new vault instance auto-unseals all tenants. - - Vault vault2 = CreateVault(); - await vault2.AutoUnsealAsync(kek); - - // Assert — Both tenants unsealed. - - vault2.IsTenantSealed(tenant1).Should().BeFalse(); - vault2.IsTenantSealed(tenant2).Should().BeFalse(); - } - - [Fact] - public async Task AutoUnseal_WithWrongKek_Fails() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await vault.SealAsync(); - - byte[] wrongKek = AesGcmEncryptor.GenerateKey(); - Vault vault2 = CreateVault(); - - Func act = () => vault2.AutoUnsealAsync(wrongKek); - - await act.Should().ThrowAsync() - .WithMessage("*KEK*"); - } - - [Fact] - public async Task AutoUnseal_WhenNotInitialized_Fails() - { - Vault vault = CreateVault(); - - Func act = () => vault.AutoUnsealAsync(kek); - - await act.Should().ThrowAsync() - .WithMessage("*not initialized*"); - } - - // ─── Manual Unseal (Disaster Recovery) ─────────────────────────────── - - [Fact] - public async Task ManualUnseal_WithEnoughShares_UnsealsVault() - { - Vault vault = CreateVault(); - InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3); - await vault.SealAsync(tenantId); - vault.IsTenantSealed(tenantId).Should().BeTrue(); - - // Act — Provide 3 of 5 shares. - - await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); - await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[2]); - UnsealResult unsealResult = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[4]); - - unsealResult.IsUnsealed.Should().BeTrue(); - vault.IsTenantSealed(tenantId).Should().BeFalse(); - } - - [Fact] - public async Task ManualUnseal_WithInsufficientShares_RemainsSealed() - { - Vault vault = CreateVault(); - InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3); - await vault.SealAsync(tenantId); - - UnsealResult result1 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); - UnsealResult result2 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]); - - result1.IsUnsealed.Should().BeFalse(); - result1.SharesProvided.Should().Be(1); - result1.SharesRequired.Should().Be(3); - - result2.IsUnsealed.Should().BeFalse(); - result2.SharesProvided.Should().Be(2); - - vault.IsTenantSealed(tenantId).Should().BeTrue(); - } - - // ─── Multi-Instance (Simulating K8s Pods) ──────────────────────────── - - [Fact] - public async Task MultiInstance_BothPodsCanAutoUnseal() - { - Vault pod1 = CreateVault(); - await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - Vault pod2 = CreateVault(); - await pod2.AutoUnsealAsync(kek); - - pod1.IsTenantSealed(tenantId).Should().BeFalse(); - pod2.IsTenantSealed(tenantId).Should().BeFalse(); - } - - [Fact] - public async Task MultiInstance_SecretWrittenByOnePodReadableByAnother() - { - Vault pod1 = CreateVault(); - await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - Vault pod2 = CreateVault(); - await pod2.AutoUnsealAsync(kek); - - await pod1.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "cloudflare-api-token"); - - string? value = await pod2.GetSecretAsync(tenantId, "clusters/prod/dns-cred"); - value.Should().Be("cloudflare-api-token"); - } - - [Fact] - public async Task MultiInstance_SealingOnePodDoesNotAffectOther() - { - Vault pod1 = CreateVault(); - await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await pod1.PutSecretAsync(tenantId, "shared/secret", "hello"); - - Vault pod2 = CreateVault(); - await pod2.AutoUnsealAsync(kek); - - await pod1.SealAsync(tenantId); - - pod1.IsTenantSealed(tenantId).Should().BeTrue(); - pod2.IsTenantSealed(tenantId).Should().BeFalse(); - - string? value = await pod2.GetSecretAsync(tenantId, "shared/secret"); - value.Should().Be("hello"); - } - - // ─── Multi-Tenant Isolation ────────────────────────────────────────── - - [Fact] - public async Task MultiTenant_SecretsAreCryptographicallyIsolated() - { - // Each tenant has their own MEK. A secret written for tenant A - // cannot be decrypted by tenant B's MEK. - - Guid tenantA = Guid.NewGuid(); - Guid tenantB = Guid.NewGuid(); - Vault vault = CreateVault(); - - await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1); - await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1); - - // Each tenant writes a secret at the same logical key but different paths. - - await vault.PutSecretAsync(tenantA, "tenants/a/config/api-key", "tenant-a-secret"); - await vault.PutSecretAsync(tenantB, "tenants/b/config/api-key", "tenant-b-secret"); - - // Each tenant can only read their own secrets. - - string? valueA = await vault.GetSecretAsync(tenantA, "tenants/a/config/api-key"); - string? valueB = await vault.GetSecretAsync(tenantB, "tenants/b/config/api-key"); - - valueA.Should().Be("tenant-a-secret"); - valueB.Should().Be("tenant-b-secret"); - } - - [Fact] - public async Task MultiTenant_SealingOneTenantDoesNotAffectAnother() - { - Guid tenantA = Guid.NewGuid(); - Guid tenantB = Guid.NewGuid(); - Vault vault = CreateVault(); - - await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1); - await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1); - - // Seal only tenant A. - - await vault.SealAsync(tenantA); - - vault.IsTenantSealed(tenantA).Should().BeTrue(); - vault.IsTenantSealed(tenantB).Should().BeFalse(); - } - - // ─── Seal / Unseal Lifecycle ───────────────────────────────────────── - - [Fact] - public async Task Seal_ClearsMasterKeyFromMemory() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - vault.IsTenantSealed(tenantId).Should().BeFalse(); - - await vault.SealAsync(tenantId); - - vault.IsTenantSealed(tenantId).Should().BeTrue(); - } - - [Fact] - public async Task SealThenAutoUnseal_Works() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); - - await vault.SealAsync(); - vault.IsSealed.Should().BeTrue(); - - await vault.AutoUnsealAsync(kek); - vault.IsTenantSealed(tenantId).Should().BeFalse(); - } - - [Fact] - public async Task RepeatedSealUnsealCycles_Work() - { - Vault vault = CreateVault(); - InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); - - await vault.SealAsync(); - await vault.AutoUnsealAsync(kek); - vault.IsTenantSealed(tenantId).Should().BeFalse(); - - await vault.SealAsync(tenantId); - await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); - await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]); - vault.IsTenantSealed(tenantId).Should().BeFalse(); - } - - // ─── KEK Rotation ──────────────────────────────────────────────────── - - [Fact] - public async Task ReWrapMasterKey_AllowsAutoUnsealWithNewKek() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await vault.PutSecretAsync(tenantId, "test/secret", "original-value"); - - byte[] newKek = AesGcmEncryptor.GenerateKey(); - - await vault.ReWrapMasterKeyAsync(tenantId, newKek); - - await vault.SealAsync(); - await vault.AutoUnsealAsync(newKek); - - string? value = await vault.GetSecretAsync(tenantId, "test/secret"); - value.Should().Be("original-value"); - } - - [Fact] - public async Task ReWrapMasterKey_OldKekNoLongerWorks() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - byte[] newKek = AesGcmEncryptor.GenerateKey(); - await vault.ReWrapMasterKeyAsync(tenantId, newKek); - await vault.SealAsync(); - - Vault vault2 = CreateVault(); - - Func act = () => vault2.AutoUnsealAsync(kek); - - await act.Should().ThrowAsync(); - } - - // ─── Secret Operations ─────────────────────────────────────────────── - - [Fact] - public async Task PutSecret_WhenUnsealed_StoresEncryptedSecret() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.PutSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential", "cloudflare-api-token-value"); - - string? retrieved = await vault.GetSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential"); - retrieved.Should().Be("cloudflare-api-token-value"); - } - - [Fact] - public async Task PutSecret_WhenSealed_ThrowsVaultSealedException() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await vault.SealAsync(tenantId); - - Func act = () => vault.PutSecretAsync(tenantId, "path/to/secret", "value"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetSecret_WhenSealed_ThrowsVaultSealedException() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await vault.PutSecretAsync(tenantId, "my/secret", "value"); - await vault.SealAsync(tenantId); - - Func act = () => vault.GetSecretAsync(tenantId, "my/secret"); - await act.Should().ThrowAsync(); - } - - [Fact] - public async Task GetSecret_NonExistentPath_ReturnsNull() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - string? result = await vault.GetSecretAsync(tenantId, "does/not/exist"); - - result.Should().BeNull(); - } - - [Fact] - public async Task PutSecret_OverwritesExistingSecret() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.PutSecretAsync(tenantId, "config/api-key", "old-value"); - await vault.PutSecretAsync(tenantId, "config/api-key", "new-value"); - - string? result = await vault.GetSecretAsync(tenantId, "config/api-key"); - - result.Should().Be("new-value"); - } - - [Fact] - public async Task PutSecret_CreatesVersionHistory() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.PutSecretAsync(tenantId, "config/api-key", "v1"); - await vault.PutSecretAsync(tenantId, "config/api-key", "v2"); - await vault.PutSecretAsync(tenantId, "config/api-key", "v3"); - - string? current = await vault.GetSecretAsync(tenantId, "config/api-key"); - int versionCount = await vault.GetSecretVersionCountAsync(tenantId, "config/api-key"); - - current.Should().Be("v3"); - versionCount.Should().Be(3); - } - - [Fact] - public async Task GetSecretVersion_RetrievesSpecificVersion() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.PutSecretAsync(tenantId, "config/api-key", "version-one"); - await vault.PutSecretAsync(tenantId, "config/api-key", "version-two"); - await vault.PutSecretAsync(tenantId, "config/api-key", "version-three"); - - string? v1 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 1); - string? v2 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 2); - string? v3 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 3); - - v1.Should().Be("version-one"); - v2.Should().Be("version-two"); - v3.Should().Be("version-three"); - } - - [Fact] - public async Task DeleteSecret_SoftDeletesAndPreventsRetrieval() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await vault.PutSecretAsync(tenantId, "temp/secret", "temporary-value"); - - await vault.DeleteSecretAsync(tenantId, "temp/secret"); - - string? result = await vault.GetSecretAsync(tenantId, "temp/secret"); - result.Should().BeNull(); - } - - [Fact] - public async Task ListSecrets_ReturnsPathsWithoutValues() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "value1"); - await vault.PutSecretAsync(tenantId, "clusters/prod/tls-key", "value2"); - await vault.PutSecretAsync(tenantId, "clusters/staging/dns-cred", "value3"); - - List paths = await vault.ListSecretsAsync(tenantId, "clusters/prod/"); - - paths.Should().HaveCount(2); - paths.Should().Contain("clusters/prod/dns-cred"); - paths.Should().Contain("clusters/prod/tls-key"); - } - - [Fact] - public async Task ListSecrets_WhenSealed_ThrowsVaultSealedException() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - await vault.SealAsync(tenantId); - - Func act = () => vault.ListSecretsAsync(tenantId, "any/"); - await act.Should().ThrowAsync(); - } - - // ─── Secrets Persist Across Seal/Unseal ────────────────────────────── - - [Fact] - public async Task Secrets_SurviveSealAutoUnsealCycle() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); - - await vault.PutSecretAsync(tenantId, "persistent/secret", "survive-seal-unseal"); - - await vault.SealAsync(); - - await vault.AutoUnsealAsync(kek); - - string? recovered = await vault.GetSecretAsync(tenantId, "persistent/secret"); - recovered.Should().Be("survive-seal-unseal"); - } - - [Fact] - public async Task Secrets_SurviveManualShamirUnseal() - { - Vault vault = CreateVault(); - InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2); - - await vault.PutSecretAsync(tenantId, "dr/secret", "disaster-recovery-value"); - - await vault.SealAsync(tenantId); - - await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]); - await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]); - - string? recovered = await vault.GetSecretAsync(tenantId, "dr/secret"); - recovered.Should().Be("disaster-recovery-value"); - } - - // ─── Audit ─────────────────────────────────────────────────────────── - - [Fact] - public async Task Operations_AreAuditLogged() - { - Vault vault = CreateVault(); - await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1); - - await vault.PutSecretAsync(tenantId, "audit/test", "value"); - await vault.GetSecretAsync(tenantId, "audit/test"); - await vault.DeleteSecretAsync(tenantId, "audit/test"); - - List entries = await vault.GetAuditLogAsync(); - - entries.Should().Contain(e => e.Operation == "initialize"); - entries.Should().Contain(e => e.Operation == "put" && e.Path == "audit/test"); - entries.Should().Contain(e => e.Operation == "get" && e.Path == "audit/test"); - entries.Should().Contain(e => e.Operation == "delete" && e.Path == "audit/test"); - } -} diff --git a/tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj b/tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj deleted file mode 100644 index eb2c385..0000000 --- a/tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - diff --git a/tests/EntKube.Web.Tests/AppEntityTests.cs b/tests/EntKube.Web.Tests/AppEntityTests.cs new file mode 100644 index 0000000..d4cc592 --- /dev/null +++ b/tests/EntKube.Web.Tests/AppEntityTests.cs @@ -0,0 +1,216 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// An app belongs to a customer and can be deployed to one or many environments. +/// The many-to-many between App and Environment is tracked via AppEnvironment. +/// +public class AppEntityTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public AppEntityTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task App_CanBeCreated_WithinCustomer() + { + // An app represents a software application owned by a customer. + // It belongs to exactly one customer. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }; + context.Customers.Add(customer); + + App app = new() + { + Id = Guid.NewGuid(), + CustomerId = customer.Id, + Name = "Payment Service" + }; + + context.Apps.Add(app); + await context.SaveChangesAsync(); + + App? retrieved = await context.Apps + .Include(a => a.Customer) + .FirstOrDefaultAsync(a => a.Name == "Payment Service"); + + retrieved.Should().NotBeNull(); + retrieved!.Customer.Name.Should().Be("Big Corp"); + } + + [Fact] + public async Task App_NameMustBeUniqueWithinCustomer() + { + // Two apps under the same customer cannot share the same name. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }; + context.Customers.Add(customer); + + context.Apps.Add(new App { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" }); + await context.SaveChangesAsync(); + + context.Apps.Add(new App { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" }); + Func act = async () => await context.SaveChangesAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task App_CanBelongToOneEnvironment() + { + // An app is deployed to environments via the AppEnvironment join. + // Here we test the simplest case: one app in one environment. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }; + context.Customers.Add(customer); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.Add(env); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" }; + context.Apps.Add(app); + + context.AppEnvironments.Add(new AppEnvironment { AppId = app.Id, EnvironmentId = env.Id }); + await context.SaveChangesAsync(); + + List links = await context.AppEnvironments + .Where(ae => ae.AppId == app.Id) + .Include(ae => ae.Environment) + .ToListAsync(); + + links.Should().HaveCount(1); + links[0].Environment.Name.Should().Be("Production"); + } + + [Fact] + public async Task App_CanBelongToMultipleEnvironments() + { + // A typical app might be deployed to dev, staging, and production + // simultaneously. Each link is a separate AppEnvironment record. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }; + context.Customers.Add(customer); + + Data.Environment dev = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Development" }; + Data.Environment staging = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Staging" }; + Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.AddRange(dev, staging, prod); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" }; + context.Apps.Add(app); + + context.AppEnvironments.AddRange( + new AppEnvironment { AppId = app.Id, EnvironmentId = dev.Id }, + new AppEnvironment { AppId = app.Id, EnvironmentId = staging.Id }, + new AppEnvironment { AppId = app.Id, EnvironmentId = prod.Id } + ); + + await context.SaveChangesAsync(); + + List links = await context.AppEnvironments + .Where(ae => ae.AppId == app.Id) + .ToListAsync(); + + links.Should().HaveCount(3); + } + + [Fact] + public async Task Environment_CanHaveMultipleApps() + { + // Multiple apps can be deployed to the same environment. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }; + context.Customers.Add(customer); + + Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.Add(prod); + + App appA = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" }; + App appB = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "Worker" }; + context.Apps.AddRange(appA, appB); + + context.AppEnvironments.AddRange( + new AppEnvironment { AppId = appA.Id, EnvironmentId = prod.Id }, + new AppEnvironment { AppId = appB.Id, EnvironmentId = prod.Id } + ); + + await context.SaveChangesAsync(); + + List links = await context.AppEnvironments + .Where(ae => ae.EnvironmentId == prod.Id) + .ToListAsync(); + + links.Should().HaveCount(2); + } + + [Fact] + public async Task AppEnvironment_CannotDuplicate() + { + // The same app cannot be linked to the same environment twice. + // We use a second context instance to bypass the change tracker + // and verify the database-level unique constraint. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }; + context.Customers.Add(customer); + + Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.Add(prod); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "API" }; + context.Apps.Add(app); + + context.AppEnvironments.Add(new AppEnvironment { AppId = app.Id, EnvironmentId = prod.Id }); + await context.SaveChangesAsync(); + + // Use a fresh context to avoid the change tracker catching the duplicate. + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + ApplicationDbContext secondContext = new(options); + secondContext.AppEnvironments.Add(new AppEnvironment { AppId = app.Id, EnvironmentId = prod.Id }); + Func act = async () => await secondContext.SaveChangesAsync(); + + await act.Should().ThrowAsync(); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/ApplicationDbContextTests.cs b/tests/EntKube.Web.Tests/ApplicationDbContextTests.cs new file mode 100644 index 0000000..2bd306d --- /dev/null +++ b/tests/EntKube.Web.Tests/ApplicationDbContextTests.cs @@ -0,0 +1,83 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Verifies that the ApplicationDbContext can be created and used with SQLite, +/// and that Identity tables are correctly defined in the model. This gives us +/// confidence that the EF Core model is valid and migrations will apply cleanly. +/// +public class ApplicationDbContextTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public ApplicationDbContextTests() + { + // We use an in-memory SQLite database that stays open for the test's + // lifetime. This is the fastest way to test EF Core behavior with a + // real relational provider (not the InMemory provider which lacks + // constraint checking). + + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public void Constructor_WithSqliteOptions_CreatesContextSuccessfully() + { + // The context should be usable — if EnsureCreated worked, the model is valid. + context.Should().NotBeNull(); + context.Database.CanConnect().Should().BeTrue(); + } + + [Fact] + public void Model_ContainsIdentityTables() + { + // The Identity schema should define the Users table via the ApplicationUser entity. + IEnumerable entityTypes = context.Model.GetEntityTypes().Select(e => e.ClrType.Name); + entityTypes.Should().Contain("ApplicationUser"); + } + + [Fact] + public async Task Users_CanInsertAndRetrieve() + { + // Arrange — create a user entity to persist. + ApplicationUser user = new() + { + Id = Guid.NewGuid().ToString(), + UserName = "testuser@example.com", + NormalizedUserName = "TESTUSER@EXAMPLE.COM", + Email = "testuser@example.com", + NormalizedEmail = "TESTUSER@EXAMPLE.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString() + }; + + // Act — insert and read back. + context.Users.Add(user); + await context.SaveChangesAsync(); + + ApplicationUser? retrieved = await context.Users.FirstOrDefaultAsync(u => u.UserName == "testuser@example.com"); + + // Assert — the user should round-trip through the database. + retrieved.Should().NotBeNull(); + retrieved!.Email.Should().Be("testuser@example.com"); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/CleuraS3ManagementTests.cs b/tests/EntKube.Web.Tests/CleuraS3ManagementTests.cs new file mode 100644 index 0000000..6635217 --- /dev/null +++ b/tests/EntKube.Web.Tests/CleuraS3ManagementTests.cs @@ -0,0 +1,399 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace EntKube.Web.Tests; + +/// +/// Tests for Cleura S3 bucket management operations exposed via StorageService. +/// These verify that the StorageService correctly validates inputs, loads +/// credentials from the vault, and delegates to OpenStackS3Service. +/// +/// Since actual S3/Keystone calls require live infrastructure, we mock +/// OpenStackS3Service and focus on the orchestration logic. +/// +public class CleuraS3ManagementTests : IDisposable +{ + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly VaultService vaultService; + private readonly Mock httpFactory; + private readonly StorageService sut; + + // We'll use a real OpenStackS3Service since the methods we're testing + // on StorageService validate state before calling it. For actual S3 calls + // that would fail, we test error paths. + + public CleuraS3ManagementTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + VaultEncryptionService encryption = new(TestRootKey); + vaultService = new VaultService(dbFactory, encryption); + httpFactory = new Mock(); + OpenStackS3Service openStackS3 = new(vaultService, httpFactory.Object); + sut = new StorageService(dbFactory, vaultService, openStackS3); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── Helpers ──────── + + private async Task<(Tenant tenant, Data.Environment env, StorageLink link)> CreateCleuraLinkWithCredentialsAsync() + { + // Create tenant, environment, OpenStack connection, and a Cleura S3 storage link + // with access/secret keys stored in the vault. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Production" + }; + + db.Set().Add(env); + + OpenStackConnection osConn = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Cleura Kna1", + AuthUrl = "https://identity.c2.citycloud.com:5000/v3", + Region = "Kna1", + Username = "testuser", + ProjectId = "project-123" + }; + + db.OpenStackConnections.Add(osConn); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Test Bucket", + Endpoint = "https://s3-kna1.citycloud.com:8080", + BucketName = "my-test-bucket", + Region = "Kna1", + OpenStackConnectionId = osConn.Id + }; + + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + // Store credentials in vault. + + await vaultService.InitializeVaultAsync(tenant.Id); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKTEST123", default); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "secrettest456", default); + + return (tenant, env, link); + } + + // ──────── ListCleuraS3BucketsAsync ──────── + + [Fact] + public async Task ListCleuraS3BucketsAsync_LinkNotFound_Throws() + { + // Arrange — no storage link exists. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, Guid.NewGuid()); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task ListCleuraS3BucketsAsync_MissingCredentials_Throws() + { + // Arrange — link exists but no vault secrets. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; + db.Set().Add(env); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "No Creds", + Endpoint = "https://s3-kna1.citycloud.com:8080", + BucketName = "bucket", + Region = "Kna1" + }; + + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + await vaultService.InitializeVaultAsync(tenant.Id); + + // Act & Assert — should throw because credentials don't exist in vault. + + Func act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, link.Id); + await act.Should().ThrowAsync() + .WithMessage("*credentials*"); + } + + [Fact] + public async Task ListCleuraS3BucketsAsync_WrongProvider_Throws() + { + // Arrange — link exists but is AWS S3, not Cleura. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; + db.Set().Add(env); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.AwsS3, + Name = "AWS Bucket", + Endpoint = "https://s3.eu-west-1.amazonaws.com", + BucketName = "bucket", + Region = "eu-west-1" + }; + + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + // Act & Assert — wrong provider should not be found. + + Func act = () => sut.ListCleuraS3BucketsAsync(tenant.Id, link.Id); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + // ──────── DeleteCleuraS3BucketAsync ──────── + + [Fact] + public async Task DeleteCleuraS3BucketAsync_LinkNotFound_Throws() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.DeleteCleuraS3BucketAsync(tenant.Id, Guid.NewGuid()); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + // ──────── RotateCleuraS3CredentialsAsync ──────── + + [Fact] + public async Task RotateCleuraS3CredentialsAsync_LinkNotFound_Throws() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.RotateCleuraS3CredentialsAsync(tenant.Id, Guid.NewGuid()); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task RotateCleuraS3CredentialsAsync_MissingOpenStackConnection_Throws() + { + // Arrange — link exists but its OpenStack connection has been deleted. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; + db.Set().Add(env); + + // Create a real OpenStack connection so the FK is valid. + + OpenStackConnection osConn = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "ToBeDeleted", + AuthUrl = "https://identity.example.com:5000/v3", + Region = "Kna1", + Username = "user" + }; + + db.OpenStackConnections.Add(osConn); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Orphaned", + Endpoint = "https://s3-kna1.citycloud.com:8080", + BucketName = "bucket", + Region = "Kna1", + OpenStackConnectionId = osConn.Id + }; + + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + // Now delete the OpenStack connection to simulate orphaned state. + + db.OpenStackConnections.Remove(osConn); + link.OpenStackConnectionId = null; + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.RotateCleuraS3CredentialsAsync(tenant.Id, link.Id); + await act.Should().ThrowAsync() + .WithMessage("*OpenStack connection not found*"); + } + + // ──────── GetStorageLinkSecretValueAsync (VaultService) ──────── + + [Fact] + public async Task GetStorageLinkSecretValueAsync_ReturnsDecryptedValue() + { + // Arrange — store a secret and retrieve it. + + (Tenant tenant, _, StorageLink link) = await CreateCleuraLinkWithCredentialsAsync(); + + // Act + + string? accessKey = await vaultService.GetStorageLinkSecretValueAsync(tenant.Id, link.Id, "ACCESS_KEY"); + + // Assert + + accessKey.Should().Be("AKTEST123"); + } + + [Fact] + public async Task GetStorageLinkSecretValueAsync_NotFound_ReturnsNull() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + await vaultService.InitializeVaultAsync(tenant.Id); + + // Act + + string? result = await vaultService.GetStorageLinkSecretValueAsync(tenant.Id, Guid.NewGuid(), "MISSING"); + + // Assert + + result.Should().BeNull(); + } + + // ──────── GetBucketCorsAsync / SetBucketCorsAsync ──────── + + [Fact] + public async Task GetBucketCorsAsync_LinkNotFound_Throws() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.GetBucketCorsAsync(tenant.Id, Guid.NewGuid()); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task SetBucketCorsAsync_LinkNotFound_Throws() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + List rules = [new() { AllowedOrigins = ["*"], AllowedMethods = ["GET"] }]; + + // Act & Assert + + Func act = () => sut.SetBucketCorsAsync(tenant.Id, Guid.NewGuid(), rules); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + // ──────── GetBucketPolicyAsync / SetBucketPolicyAsync ──────── + + [Fact] + public async Task GetBucketPolicyAsync_LinkNotFound_Throws() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.GetBucketPolicyAsync(tenant.Id, Guid.NewGuid()); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task SetBucketPolicyAsync_LinkNotFound_Throws() + { + // Arrange + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "T", Slug = "t" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.SetBucketPolicyAsync(tenant.Id, Guid.NewGuid(), "{}"); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } +} diff --git a/tests/EntKube.Web.Tests/ClusterEntityTests.cs b/tests/EntKube.Web.Tests/ClusterEntityTests.cs new file mode 100644 index 0000000..e2aebf9 --- /dev/null +++ b/tests/EntKube.Web.Tests/ClusterEntityTests.cs @@ -0,0 +1,168 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// A Kubernetes cluster belongs to a tenant and is associated with an environment. +/// One environment can have many clusters (e.g. a production environment might +/// span multiple clusters for redundancy or regional distribution). +/// +public class ClusterEntityTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public ClusterEntityTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task Cluster_CanBeCreated_WithTenantAndEnvironment() + { + // A cluster is registered under a tenant and placed into an environment. + // It has a name and an API server endpoint for connectivity. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-eu-west-1", + ApiServerUrl = "https://k8s.prod-eu.example.com:6443" + }; + + context.KubernetesClusters.Add(cluster); + await context.SaveChangesAsync(); + + KubernetesCluster? retrieved = await context.KubernetesClusters + .Include(c => c.Tenant) + .Include(c => c.Environment) + .FirstOrDefaultAsync(c => c.Name == "prod-eu-west-1"); + + retrieved.Should().NotBeNull(); + retrieved!.Tenant.Name.Should().Be("Acme"); + retrieved.Environment.Name.Should().Be("Production"); + retrieved.ApiServerUrl.Should().Be("https://k8s.prod-eu.example.com:6443"); + } + + [Fact] + public async Task Cluster_NameMustBeUniqueWithinTenant() + { + // Two clusters in the same tenant cannot share the same name. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.Add(env); + + context.KubernetesClusters.Add(new KubernetesCluster + { + Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, + Name = "prod-01", ApiServerUrl = "https://a.example.com:6443" + }); + + await context.SaveChangesAsync(); + + context.KubernetesClusters.Add(new KubernetesCluster + { + Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, + Name = "prod-01", ApiServerUrl = "https://b.example.com:6443" + }); + + Func act = async () => await context.SaveChangesAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Environment_CanHaveMultipleClusters() + { + // A production environment might span multiple clusters for + // redundancy or regional distribution. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.Add(prod); + + context.KubernetesClusters.AddRange( + new KubernetesCluster + { + Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = prod.Id, + Name = "prod-eu-west-1", ApiServerUrl = "https://eu-west.example.com:6443" + }, + new KubernetesCluster + { + Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = prod.Id, + Name = "prod-us-east-1", ApiServerUrl = "https://us-east.example.com:6443" + } + ); + + await context.SaveChangesAsync(); + + List clusters = await context.KubernetesClusters + .Where(c => c.EnvironmentId == prod.Id) + .ToListAsync(); + + clusters.Should().HaveCount(2); + } + + [Fact] + public async Task Tenant_CanHaveClustersAcrossEnvironments() + { + // A tenant has clusters spread across different environments. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Data.Environment dev = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Development" }; + Data.Environment prod = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }; + context.Environments.AddRange(dev, prod); + + context.KubernetesClusters.AddRange( + new KubernetesCluster + { + Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = dev.Id, + Name = "dev-01", ApiServerUrl = "https://dev.example.com:6443" + }, + new KubernetesCluster + { + Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = prod.Id, + Name = "prod-01", ApiServerUrl = "https://prod.example.com:6443" + } + ); + + await context.SaveChangesAsync(); + + List clusters = await context.KubernetesClusters + .Where(c => c.TenantId == tenant.Id) + .ToListAsync(); + + clusters.Should().HaveCount(2); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/CnpgServiceTests.cs b/tests/EntKube.Web.Tests/CnpgServiceTests.cs new file mode 100644 index 0000000..6fc0245 --- /dev/null +++ b/tests/EntKube.Web.Tests/CnpgServiceTests.cs @@ -0,0 +1,846 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace EntKube.Web.Tests; + +/// +/// Tests for CnpgService — the orchestration layer for managed CloudNativePG clusters. +/// Covers cluster creation, deletion, upgrade, backup, restore, and database management. +/// +/// The Kubernetes API interactions are mocked via IKubernetesClientFactory. +/// Database state management and vault secret creation are tested against +/// a real SQLite in-memory database to verify the full orchestration flow. +/// +public class CnpgServiceTests : IDisposable +{ + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly VaultService vaultService; + private readonly Mock k8sFactory; + private readonly CnpgService sut; + + public CnpgServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + VaultEncryptionService encryption = new(TestRootKey); + vaultService = new VaultService(dbFactory, encryption); + k8sFactory = new Mock(); + sut = new CnpgService(dbFactory, vaultService, k8sFactory.Object); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── Helpers ──────── + + private async Task<(Tenant tenant, KubernetesCluster cluster, StorageLink storageLink)> SeedTenantWithClusterAsync() + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Production" + }; + + db.Set().Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = "apiVersion: v1\nkind: Config\nclusters: []" + }; + + db.KubernetesClusters.Add(cluster); + + // Install the CNPG operator component on this cluster. + + ClusterComponent cnpgOperator = new() + { + Id = Guid.NewGuid(), + ClusterId = cluster.Id, + Name = "cloudnative-pg", + ComponentType = "helm", + Status = ComponentStatus.Installed, + Namespace = "cnpg-system" + }; + + db.ClusterComponents.Add(cnpgOperator); + + // Create a storage link for Barman backups. + + StorageLink storageLink = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Backup Bucket", + Endpoint = "https://s3-kna1.citycloud.com", + BucketName = "cnpg-backups", + Region = "Kna1" + }; + + db.StorageLinks.Add(storageLink); + await db.SaveChangesAsync(); + + // Initialize vault and store S3 credentials. + + await vaultService.InitializeVaultAsync(tenant.Id); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, storageLink.Id, "ACCESS_KEY", "test-access-key"); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, storageLink.Id, "SECRET_KEY", "test-secret-key"); + + return (tenant, cluster, storageLink); + } + + // ──────── CreateClusterAsync ──────── + + [Fact] + public async Task CreateClusterAsync_ValidInput_CreatesDbRecordAndReturnsCluster() + { + // Arrange — a tenant with a cluster and CNPG operator installed. + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act — create a new managed CNPG cluster. + + CnpgCluster result = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "my-pg", "databases", 3, "10Gi", + storageLink.Id, "0 0 2 * * *"); + + // Assert — the cluster record should exist in the database. + + result.Should().NotBeNull(); + result.Name.Should().Be("my-pg"); + result.Namespace.Should().Be("databases"); + result.PostgresVersion.Should().Be("18"); + result.Instances.Should().Be(3); + result.StorageSize.Should().Be("10Gi"); + result.StorageLinkId.Should().Be(storageLink.Id); + result.BackupSchedule.Should().Be("0 0 2 * * *"); + result.Status.Should().Be(CnpgClusterStatus.Creating); + + CnpgCluster? persisted = await db.CnpgClusters.FindAsync(result.Id); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task CreateClusterAsync_WithoutBackup_CreatesClusterWithNoStorageLink() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act — create without backup configuration. + + CnpgCluster result = await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "standalone-pg", "default", 1, "5Gi", + storageLinkId: null, backupSchedule: null); + + // Assert + + result.StorageLinkId.Should().BeNull(); + result.BackupSchedule.Should().BeNull(); + } + + [Fact] + public async Task CreateClusterAsync_NoCnpgOperator_ThrowsInvalidOperation() + { + // Arrange — tenant with a cluster but NO CNPG operator. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "NoCnpg", Slug = "no-cnpg" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Dev" }; + db.Set().Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "dev-cluster", + ApiServerUrl = "https://k8s.dev.example.com", + Kubeconfig = "fake" + }; + + db.KubernetesClusters.Add(cluster); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.CreateClusterAsync( + tenant.Id, cluster.Id, "pg", "default", 1, "5Gi", null, null); + + await act.Should().ThrowAsync() + .WithMessage("*CloudNativePG operator*not installed*"); + } + + [Fact] + public async Task CreateClusterAsync_AppliesManifestToKubernetes() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + List appliedManifests = []; + string? appliedKubeconfig = null; + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((manifest, kubeconfig, _) => + { + appliedManifests.Add(manifest); + appliedKubeconfig = kubeconfig; + }) + .Returns(Task.CompletedTask); + + // Act + + await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "backup-pg", "databases", 3, "20Gi", + storageLink.Id, "0 0 2 * * *"); + + // Assert — verify the manifests contain expected CNPG configuration. + + string allManifests = string.Join("\n", appliedManifests); + allManifests.Should().Contain("kind: Cluster"); + allManifests.Should().Contain("postgresql.cnpg.io/v1"); + allManifests.Should().Contain("name: backup-pg"); + allManifests.Should().Contain("namespace: databases"); + allManifests.Should().Contain("instances: 3"); + allManifests.Should().Contain("size: 20Gi"); + allManifests.Should().Contain("postgresql:18"); + allManifests.Should().Contain("barman-cloud.cloudnative-pg.io"); + allManifests.Should().Contain("kind: ObjectStore"); + allManifests.Should().Contain("barmanObjectName: backup-pg-object-store"); + allManifests.Should().Contain("cnpg-backups"); + appliedKubeconfig.Should().NotBeNullOrEmpty(); + } + + // ──────── DeleteClusterAsync ──────── + + [Fact] + public async Task DeleteClusterAsync_RemovesFromK8sAndDatabase() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "to-delete", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.DeleteManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + + await sut.DeleteClusterAsync(tenant.Id, cnpg.Id); + + // Assert — use a fresh context to avoid EF tracking cache. + + using ApplicationDbContext verifyDb = dbFactory.CreateDbContext(); + CnpgCluster? deleted = await verifyDb.CnpgClusters + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == cnpg.Id); + deleted.Should().BeNull(); + } + + // ──────── UpgradeClusterAsync ──────── + + [Fact] + public async Task UpgradeClusterAsync_MinorVersion_UpdatesVersionAndAppliesManifest() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "upgrade-me", + Namespace = "databases", + PostgresVersion = "18.1", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act — minor version bump within the same major + + await sut.UpgradeClusterAsync(tenant.Id, cnpg.Id, "18.2"); + + // Assert + + using ApplicationDbContext verifyDb = dbFactory.CreateDbContext(); + CnpgCluster? upgraded = await verifyDb.CnpgClusters.FindAsync(cnpg.Id); + upgraded!.PostgresVersion.Should().Be("18.2"); + upgraded.Status.Should().Be(CnpgClusterStatus.Upgrading); + } + + [Fact] + public async Task UpgradeClusterAsync_MajorVersion_ThrowsWithRestoreGuidance() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "upgrade-major", + Namespace = "databases", + PostgresVersion = "17", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + // Act & Assert — major version jump should be rejected + + Func act = () => sut.UpgradeClusterAsync(tenant.Id, cnpg.Id, "18"); + + await act.Should().ThrowAsync() + .WithMessage("*Major version upgrades*restore*"); + } + + // ──────── MajorUpgradeAsync ──────── + + [Fact] + public async Task MajorUpgradeAsync_RestoresNewClusterAndRemovesOld() + { + // Arrange — a v17 cluster with a database and backup storage + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "prod-db", + Namespace = "databases", + PostgresVersion = "17", + Instances = 3, + StorageSize = "20Gi", + StorageLinkId = storageLink.Id, + Status = CnpgClusterStatus.Running + }; + + CnpgDatabase database = new() + { + Id = Guid.NewGuid(), + CnpgClusterId = cnpg.Id, + Name = "app_data", + Owner = "app_data_owner", + Status = CnpgDatabaseStatus.Ready + }; + + db.CnpgClusters.Add(cnpg); + db.CnpgDatabases.Add(database); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + k8sFactory.Setup(f => f.DeleteManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + + CnpgCluster result = await sut.MajorUpgradeAsync(tenant.Id, cnpg.Id, "18"); + + // Assert — new cluster takes the original name and version + + result.Name.Should().Be("prod-db"); + result.PostgresVersion.Should().Be("18"); + result.Status.Should().Be(CnpgClusterStatus.Running); + + // Assert — old cluster record is removed + + using ApplicationDbContext verifyDb = dbFactory.CreateDbContext(); + bool oldExists = await verifyDb.CnpgClusters.AnyAsync(c => c.Id == cnpg.Id); + oldExists.Should().BeFalse(); + + // Assert — databases transferred to the new cluster + + CnpgDatabase? movedDb = await verifyDb.CnpgDatabases.FindAsync(database.Id); + movedDb!.CnpgClusterId.Should().Be(result.Id); + + // Assert — K8s operations: backup applied, old deleted, temp deleted, final applied + + k8sFactory.Verify(f => f.ApplyManifestAsync( + It.Is(m => m.Contains("kind: Backup")), + It.IsAny(), It.IsAny()), Times.Once); + + k8sFactory.Verify(f => f.DeleteManifestAsync( + "Cluster", "prod-db", "databases", + It.IsAny(), It.IsAny()), Times.Once); + + k8sFactory.Verify(f => f.DeleteManifestAsync( + "Cluster", "prod-db-v18", "databases", + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task MajorUpgradeAsync_WithoutStorage_Throws() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "no-backup", + Namespace = "databases", + PostgresVersion = "17", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.MajorUpgradeAsync(tenant.Id, cnpg.Id, "18"); + + await act.Should().ThrowAsync() + .WithMessage("*backup storage*"); + } + + // ──────── BackupAsync ──────── + + [Fact] + public async Task BackupAsync_CreatesBackupRecordAndAppliesCR() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "backup-target", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + StorageLinkId = storageLink.Id, + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + + CnpgBackup backup = await sut.BackupAsync(tenant.Id, cnpg.Id); + + // Assert + + backup.Should().NotBeNull(); + backup.CnpgClusterId.Should().Be(cnpg.Id); + backup.Status.Should().Be(CnpgBackupStatus.Running); + backup.Type.Should().Be(CnpgBackupType.OnDemand); + backup.Name.Should().StartWith("backup-target-"); + } + + [Fact] + public async Task BackupAsync_NoStorageLink_ThrowsInvalidOperation() + { + // Arrange — cluster without backup storage configured. + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "no-backup-storage", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + StorageLinkId = null, + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + // Act & Assert + + Func act = () => sut.BackupAsync(tenant.Id, cnpg.Id); + + await act.Should().ThrowAsync() + .WithMessage("*backup storage*not configured*"); + } + + // ──────── RestoreAsync ──────── + + [Fact] + public async Task RestoreAsync_CreatesNewClusterWithRecoveryBootstrap() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + CnpgCluster source = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "source-cluster", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + StorageLinkId = storageLink.Id, + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(source); + await db.SaveChangesAsync(); + + string? appliedManifest = null; + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((manifest, _, _) => appliedManifest = manifest) + .Returns(Task.CompletedTask); + + DateTime targetTime = new(2026, 5, 17, 10, 30, 0, DateTimeKind.Utc); + + // Act + + CnpgCluster restored = await sut.RestoreAsync( + tenant.Id, source.Id, "restored-cluster", targetTime); + + // Assert — new cluster created with Restoring status. + + restored.Name.Should().Be("restored-cluster"); + restored.Status.Should().Be(CnpgClusterStatus.Restoring); + restored.StorageLinkId.Should().Be(storageLink.Id); + + appliedManifest.Should().Contain("recovery"); + appliedManifest.Should().Contain("2026-05-17"); + appliedManifest.Should().Contain("restored-cluster"); + } + + // ──────── CreateDatabaseAsync ──────── + + [Fact] + public async Task CreateDatabaseAsync_CreatesRecordAndStoresSecrets() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "app-cluster", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ExecuteSqlAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + + CnpgDatabase database = await sut.CreateDatabaseAsync( + tenant.Id, cnpg.Id, "myapp"); + + // Assert — database record created. + + database.Should().NotBeNull(); + database.Name.Should().Be("myapp"); + database.Owner.Should().Be("myapp_owner"); + database.Status.Should().Be(CnpgDatabaseStatus.Ready); + + CnpgDatabase? persisted = await db.CnpgDatabases.FindAsync(database.Id); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task CreateDatabaseAsync_StoresVaultSecretsTaggedForK8sSync() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "secret-cluster", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ExecuteSqlAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + + CnpgDatabase database = await sut.CreateDatabaseAsync( + tenant.Id, cnpg.Id, "analytics"); + + // Assert — vault secrets should be created with K8s sync tags. + + List secrets = await db.VaultSecrets + .Where(s => s.CnpgDatabaseId == database.Id) + .ToListAsync(); + + secrets.Should().HaveCountGreaterThanOrEqualTo(5); + secrets.Should().Contain(s => s.Name == "HOST"); + secrets.Should().Contain(s => s.Name == "PORT"); + secrets.Should().Contain(s => s.Name == "DATABASE"); + secrets.Should().Contain(s => s.Name == "USERNAME"); + secrets.Should().Contain(s => s.Name == "PASSWORD"); + + // All secrets should be tagged for K8s sync. + + secrets.Should().OnlyContain(s => s.SyncToKubernetes == true); + secrets.Should().OnlyContain(s => s.KubernetesSecretName == "secret-cluster-analytics-credentials"); + secrets.Should().OnlyContain(s => s.KubernetesNamespace == "databases"); + } + + // ──────── DeleteDatabaseAsync ──────── + + [Fact] + public async Task DeleteDatabaseAsync_RemovesDatabaseAndSecrets() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, _) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "db-cluster", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.Add(cnpg); + await db.SaveChangesAsync(); + + k8sFactory.Setup(f => f.ExecuteSqlAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + CnpgDatabase database = await sut.CreateDatabaseAsync( + tenant.Id, cnpg.Id, "to-remove"); + + // Act + + await sut.DeleteDatabaseAsync(tenant.Id, cnpg.Id, database.Id); + + // Assert + + CnpgDatabase? deleted = await db.CnpgDatabases.FindAsync(database.Id); + deleted.Should().BeNull(); + + List remainingSecrets = await db.VaultSecrets + .Where(s => s.CnpgDatabaseId == database.Id) + .ToListAsync(); + remainingSecrets.Should().BeEmpty(); + } + + // ──────── GetClustersAsync ──────── + + [Fact] + public async Task GetClustersAsync_ReturnsOnlyTenantClusters() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + CnpgCluster cnpg1 = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "cluster-1", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "10Gi", + Status = CnpgClusterStatus.Running + }; + + CnpgCluster cnpg2 = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + KubernetesClusterId = cluster.Id, + Name = "cluster-2", + Namespace = "databases", + PostgresVersion = "18", + StorageSize = "20Gi", + Status = CnpgClusterStatus.Running + }; + + db.CnpgClusters.AddRange(cnpg1, cnpg2); + await db.SaveChangesAsync(); + + // Act + + List results = await sut.GetClustersAsync(tenant.Id); + + // Assert + + results.Should().HaveCount(2); + results.Should().Contain(c => c.Name == "cluster-1"); + results.Should().Contain(c => c.Name == "cluster-2"); + } + + // ──────── Manifest Generation ──────── + + [Fact] + public async Task CreateClusterAsync_ManifestIncludesBackupStorageCredentials() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + List appliedManifests = []; + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((manifest, _, _) => appliedManifests.Add(manifest)) + .Returns(Task.CompletedTask); + + // Act + + await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "cred-test", "databases", 3, "10Gi", + storageLink.Id, "0 0 2 * * *"); + + // Assert — manifests should reference the K8s secret for S3 credentials. + + string allManifests = string.Join("\n", appliedManifests); + allManifests.Should().Contain("s3Credentials"); + allManifests.Should().Contain("endpointURL"); + allManifests.Should().Contain("s3-kna1.citycloud.com"); + allManifests.Should().Contain("cnpg-backups"); + } + + [Fact] + public async Task CreateClusterAsync_ManifestIncludesScheduledBackup() + { + // Arrange + + (Tenant tenant, KubernetesCluster cluster, StorageLink storageLink) = await SeedTenantWithClusterAsync(); + + string? appliedManifest = null; + k8sFactory.Setup(f => f.ApplyManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((manifest, _, _) => appliedManifest = manifest) + .Returns(Task.CompletedTask); + + // Act + + await sut.CreateClusterAsync( + tenant.Id, cluster.Id, "sched-test", "databases", 3, "10Gi", + storageLink.Id, "0 0 2 * * *"); + + // Assert — a ScheduledBackup resource should be in the manifest. + + appliedManifest.Should().Contain("ScheduledBackup"); + appliedManifest.Should().Contain("0 0 2 * * *"); + } +} diff --git a/tests/EntKube.Web.Tests/ComponentCatalogTests.cs b/tests/EntKube.Web.Tests/ComponentCatalogTests.cs new file mode 100644 index 0000000..071002e --- /dev/null +++ b/tests/EntKube.Web.Tests/ComponentCatalogTests.cs @@ -0,0 +1,368 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the ComponentCatalog — the static registry of well-known +/// infrastructure components. Verifies that catalog entries are valid, +/// the lookup methods work, and that catalog-based registration flows +/// correctly through the lifecycle service. +/// +public class ComponentCatalogTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly ComponentLifecycleService lifecycleService; + private readonly Guid clusterId = Guid.NewGuid(); + + public ComponentCatalogTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + // Seed a tenant, environment, and cluster for integration tests. + + Guid tenantId = Guid.NewGuid(); + Guid envId = Guid.NewGuid(); + Tenant tenant = new() { Id = tenantId, Name = "CatalogTenant", Slug = "catalog" }; + Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" }; + KubernetesCluster cluster = new() + { + Id = clusterId, + TenantId = tenantId, + EnvironmentId = envId, + Name = "catalog-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = "apiVersion: v1\nkind: Config" + }; + + db.Set().Add(tenant); + db.Set().Add(env); + db.KubernetesClusters.Add(cluster); + db.SaveChanges(); + + byte[] testRootKey = Convert.FromBase64String("dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + VaultEncryptionService encryption = new(testRootKey); + VaultService vaultService = new(dbFactory, encryption); + lifecycleService = new ComponentLifecycleService(dbFactory, vaultService); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── Catalog integrity ──────── + + [Fact] + public void Catalog_HasEntries() + { + // The catalog should never be empty — we ship with known components. + + ComponentCatalog.Entries.Should().NotBeEmpty(); + } + + [Fact] + public void Catalog_AllEntriesHaveRequiredFields() + { + // Every catalog entry must have all the fields needed for a valid + // Helm install. Missing fields would cause install failures. + + foreach (CatalogEntry entry in ComponentCatalog.Entries) + { + entry.Key.Should().NotBeNullOrWhiteSpace($"entry must have a key"); + entry.DisplayName.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a display name"); + entry.Description.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a description"); + entry.Icon.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have an icon"); + entry.Category.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a category"); + entry.HelmRepoUrl.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a Helm repo URL"); + entry.HelmChartName.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a Helm chart name"); + entry.DefaultNamespace.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a default namespace"); + } + } + + [Fact] + public void Catalog_KeysAreUnique() + { + // Duplicate keys would cause lookup ambiguity. + + List keys = ComponentCatalog.Entries.Select(e => e.Key).ToList(); + keys.Should().OnlyHaveUniqueItems(); + } + + // ──────── Lookup ──────── + + [Fact] + public void GetByKey_ExistingEntry_ReturnsEntry() + { + CatalogEntry? entry = ComponentCatalog.GetByKey("kube-prometheus-stack"); + + entry.Should().NotBeNull(); + entry!.DisplayName.Should().Be("Kube Prometheus Stack"); + entry.Category.Should().Be("Monitoring"); + } + + [Fact] + public void GetByKey_CaseInsensitive_ReturnsEntry() + { + // Operators shouldn't have to remember exact casing. + + CatalogEntry? entry = ComponentCatalog.GetByKey("MINIO"); + + entry.Should().NotBeNull(); + entry!.Key.Should().Be("minio"); + } + + [Fact] + public void GetByKey_Unknown_ReturnsNull() + { + CatalogEntry? entry = ComponentCatalog.GetByKey("nonexistent-component"); + + entry.Should().BeNull(); + } + + [Fact] + public void GetByCategory_GroupsCorrectly() + { + IReadOnlyList> groups = ComponentCatalog.GetByCategory(); + + groups.Should().NotBeEmpty(); + groups.SelectMany(g => g).Should().HaveCount(ComponentCatalog.Entries.Count); + } + + // ──────── Registration from catalog ──────── + + [Fact] + public void ToRegistration_FillsAllHelmDetails() + { + // When we create a registration from a catalog entry, it should + // carry over all the Helm details so the operator doesn't need + // to enter them manually. + + CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!; + + ComponentRegistration registration = ComponentCatalog.ToRegistration(entry); + + registration.Name.Should().Be("kube-prometheus-stack"); + registration.ComponentType.Should().Be("HelmChart"); + registration.Namespace.Should().Be("monitoring"); + registration.HelmRepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts"); + registration.HelmChartName.Should().Be("kube-prometheus-stack"); + registration.ReleaseName.Should().Be("kube-prometheus-stack"); + registration.HelmValues.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task RegisterFromCatalog_CreatesComponentWithDefaults() + { + // The full flow: pick from catalog → register → component is ready + // with all Helm details pre-filled. + + CatalogEntry entry = ComponentCatalog.GetByKey("minio")!; + ComponentRegistration registration = ComponentCatalog.ToRegistration(entry); + + ClusterComponent component = await lifecycleService.RegisterComponentAsync(clusterId, registration); + + component.Name.Should().Be("minio"); + component.HelmRepoUrl.Should().Be("https://charts.min.io/"); + component.HelmChartName.Should().Be("minio"); + component.Namespace.Should().Be("minio"); + component.Status.Should().Be(ComponentStatus.NotInstalled); + component.HelmValues.Should().Contain("rootUser"); + } + + [Fact] + public async Task RegisterFromCatalog_CanOverrideValues() + { + // Operators should be able to modify the default values before + // registering — for example, changing resource limits or passwords. + + CatalogEntry entry = ComponentCatalog.GetByKey("cert-manager")!; + ComponentRegistration registration = ComponentCatalog.ToRegistration(entry); + + // Override the values with custom config. + + registration.HelmValues = """ + crds: + enabled: true + resources: + requests: + memory: 256Mi + cpu: 100m + """; + + ClusterComponent component = await lifecycleService.RegisterComponentAsync(clusterId, registration); + + component.HelmValues.Should().Contain("256Mi"); + component.HelmValues.Should().NotContain("128Mi"); + } + + [Fact] + public async Task RegisterFromCatalog_DuplicateRejected() + { + // Can't install the same catalog component twice on one cluster. + + CatalogEntry entry = ComponentCatalog.GetByKey("traefik")!; + ComponentRegistration registration = ComponentCatalog.ToRegistration(entry); + await lifecycleService.RegisterComponentAsync(clusterId, registration); + + // Second attempt should be rejected. + + Func secondAttempt = () => lifecycleService.RegisterComponentAsync( + clusterId, ComponentCatalog.ToRegistration(entry)); + + await secondAttempt.Should().ThrowAsync() + .WithMessage("*already exists*"); + } + + // ──────── Dependency resolution ──────── + + [Fact] + public void CheckDependencies_NoDependencies_Satisfied() + { + // Components with no dependencies should always pass the check. + + CatalogEntry entry = ComponentCatalog.GetByKey("minio")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []); + + result.IsSatisfied.Should().BeTrue(); + result.MissingDependencies.Should().BeEmpty(); + result.MissingOneOfRequirements.Should().BeEmpty(); + } + + [Fact] + public void CheckDependencies_DirectDependencyMissing_NotSatisfied() + { + // kube-prometheus-stack requires cert-manager and letsencrypt-issuer. + // If they're not installed, the check should report them missing. + + CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []); + + result.IsSatisfied.Should().BeFalse(); + result.MissingDependencies.Should().Contain("cert-manager"); + result.MissingDependencies.Should().Contain("letsencrypt-issuer"); + } + + [Fact] + public void CheckDependencies_OneOfMissing_NotSatisfied() + { + // kube-prometheus-stack needs either traefik or istio. + // If neither is present, the one-of requirement should be missing. + + CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies( + entry, ["cert-manager", "letsencrypt-issuer"]); + + result.IsSatisfied.Should().BeFalse(); + result.MissingOneOfRequirements.Should().HaveCount(1); + result.MissingOneOfRequirements[0].Label.Should().Be("Ingress Controller"); + } + + [Fact] + public void CheckDependencies_TraefikSatisfiesIngress() + { + // Installing traefik should satisfy the "one of ingress" requirement. + + CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies( + entry, ["cert-manager", "letsencrypt-issuer", "traefik"]); + + result.IsSatisfied.Should().BeTrue(); + } + + [Fact] + public void CheckDependencies_IstioSatisfiesIngress() + { + // Installing istio should also satisfy the "one of ingress" requirement. + + CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies( + entry, ["cert-manager", "letsencrypt-issuer", "istio"]); + + result.IsSatisfied.Should().BeTrue(); + } + + [Fact] + public void CheckDependencies_IstioRequiresBase() + { + // The istio gateway depends on istio-base (istiod). + + CatalogEntry entry = ComponentCatalog.GetByKey("istio")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []); + + result.IsSatisfied.Should().BeFalse(); + result.MissingDependencies.Should().Contain("istio-base"); + } + + [Fact] + public void CheckDependencies_IstioWithBase_Satisfied() + { + CatalogEntry entry = ComponentCatalog.GetByKey("istio")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, ["istio-base"]); + + result.IsSatisfied.Should().BeTrue(); + } + + [Fact] + public void CheckDependencies_LetsEncryptRequiresCertManager() + { + CatalogEntry entry = ComponentCatalog.GetByKey("letsencrypt-issuer")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []); + + result.IsSatisfied.Should().BeFalse(); + result.MissingDependencies.Should().Contain("cert-manager"); + } + + [Fact] + public void CheckDependencies_CaseInsensitive() + { + // Installed component names should match case-insensitively. + + CatalogEntry entry = ComponentCatalog.GetByKey("letsencrypt-issuer")!; + + DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, ["Cert-Manager"]); + + result.IsSatisfied.Should().BeTrue(); + } + + [Fact] + public void Catalog_IstioBaseExists() + { + // Ensure the istio-base entry exists since istio depends on it. + + CatalogEntry? entry = ComponentCatalog.GetByKey("istio-base"); + entry.Should().NotBeNull(); + entry!.HelmChartName.Should().Be("istiod"); + } + + [Fact] + public void Catalog_LetsEncryptIssuerExists() + { + CatalogEntry? entry = ComponentCatalog.GetByKey("letsencrypt-issuer"); + entry.Should().NotBeNull(); + entry!.Dependencies.Should().Contain("cert-manager"); + } +} diff --git a/tests/EntKube.Web.Tests/ComponentLifecycleServiceTests.cs b/tests/EntKube.Web.Tests/ComponentLifecycleServiceTests.cs new file mode 100644 index 0000000..4f3853d --- /dev/null +++ b/tests/EntKube.Web.Tests/ComponentLifecycleServiceTests.cs @@ -0,0 +1,495 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for ComponentLifecycleService — the service that manages the full +/// lifecycle of cluster components (install, configure, upgrade, uninstall). +/// Tests cover the data-layer operations and validation; actual Helm CLI +/// execution requires a live cluster + helm binary. +/// +public class ComponentLifecycleServiceTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly ComponentLifecycleService sut; + private readonly VaultService vaultService; + private readonly Guid tenantId = Guid.NewGuid(); + private readonly Guid envId = Guid.NewGuid(); + private readonly Guid clusterId = Guid.NewGuid(); + + public ComponentLifecycleServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + // Seed a tenant, environment, and cluster for component lifecycle tests. + + Tenant tenant = new() { Id = tenantId, Name = "LifecycleTenant", Slug = "lifecycle" }; + Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" }; + KubernetesCluster cluster = new() + { + Id = clusterId, + TenantId = tenantId, + EnvironmentId = envId, + Name = "lifecycle-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://k8s.example.com\n name: test\ncontexts:\n- context:\n cluster: test\n user: test\n name: test\ncurrent-context: test\nusers:\n- name: test\n user:\n token: fake-token" + }; + + db.Set().Add(tenant); + db.Set().Add(env); + db.KubernetesClusters.Add(cluster); + db.SaveChanges(); + + byte[] testRootKey = Convert.FromBase64String("dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + VaultEncryptionService encryption = new(testRootKey); + vaultService = new VaultService(dbFactory, encryption); + sut = new ComponentLifecycleService(dbFactory, vaultService); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── RegisterComponentAsync ──────── + + [Fact] + public async Task RegisterComponentAsync_ValidInput_CreatesWithNotInstalledStatus() + { + // Arrange — a new Helm component registration for kube-prometheus-stack. + + ComponentRegistration registration = new() + { + Name = "kube-prometheus-stack", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmRepoUrl = "https://prometheus-community.github.io/helm-charts", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0" + }; + + // Act + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, registration); + + // Assert — component exists in DB with NotInstalled status. + + component.Should().NotBeNull(); + component.Status.Should().Be(ComponentStatus.NotInstalled); + component.Namespace.Should().Be("monitoring"); + component.HelmRepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts"); + component.HelmChartName.Should().Be("kube-prometheus-stack"); + component.HelmChartVersion.Should().Be("65.1.0"); + component.ReleaseName.Should().Be("kube-prometheus-stack"); + } + + [Fact] + public async Task RegisterComponentAsync_DuplicateName_Throws() + { + // Arrange — register a component, then try to register another with the same name. + + ComponentRegistration registration = new() + { + Name = "duplicate-comp", + ComponentType = "HelmChart", + Namespace = "default" + }; + + await sut.RegisterComponentAsync(clusterId, registration); + + // Act & Assert + + Func act = () => sut.RegisterComponentAsync(clusterId, registration); + await act.Should().ThrowAsync() + .WithMessage("*already exists*"); + } + + // ──────── UpdateConfigurationAsync ──────── + + [Fact] + public async Task UpdateConfigurationAsync_ExistingComponent_UpdatesHelmValues() + { + // Arrange — register a component, then update its values. + + ComponentRegistration reg = new() + { + Name = "config-test", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmChartName = "kube-prometheus-stack" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + string newValues = "alertmanager:\n enabled: true\ngrafana:\n enabled: true"; + + // Act + + ClusterComponent updated = await sut.UpdateConfigurationAsync( + component.Id, newValues, chartVersion: "65.2.0"); + + // Assert + + updated.HelmValues.Should().Be(newValues); + updated.HelmChartVersion.Should().Be("65.2.0"); + } + + [Fact] + public async Task UpdateConfigurationAsync_NonExistentComponent_Throws() + { + // Act & Assert + + Func act = () => sut.UpdateConfigurationAsync(Guid.NewGuid(), "values: true"); + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + // ──────── PrepareInstallAsync — validates before install ──────── + + [Fact] + public async Task PrepareInstallAsync_ValidComponent_SetsInstallingStatus() + { + // Arrange — a properly configured component ready to install. + + ComponentRegistration reg = new() + { + Name = "ready-to-install", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmRepoUrl = "https://prometheus-community.github.io/helm-charts", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + // Act + + ClusterComponent prepared = await sut.PrepareInstallAsync(component.Id); + + // Assert — status should transition to Installing. + + prepared.Status.Should().Be(ComponentStatus.Installing); + } + + [Fact] + public async Task PrepareInstallAsync_MissingHelmChart_ReturnsFailure() + { + // Arrange — component without chart info can't be installed. + + ComponentRegistration reg = new() + { + Name = "no-chart", + ComponentType = "HelmChart", + Namespace = "default" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + // Act & Assert + + Func act = () => sut.PrepareInstallAsync(component.Id); + await act.Should().ThrowAsync() + .WithMessage("*chart name*"); + } + + [Fact] + public async Task PrepareInstallAsync_AlreadyInstalled_Throws() + { + // Arrange — component that's already in Installed state. + + ComponentRegistration reg = new() + { + Name = "already-installed", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + // Manually set status to Installed to simulate. + // Re-fetch from test db since the returned entity is detached. + ClusterComponent tracked = (await db.Set().FindAsync(component.Id))!; + tracked.Status = ComponentStatus.Installed; + await db.SaveChangesAsync(); + + // Act & Assert — can't install something that's already installed (use upgrade instead). + + Func act = () => sut.PrepareInstallAsync(component.Id); + await act.Should().ThrowAsync() + .WithMessage("*already installed*"); + } + + // ──────── MarkInstallResultAsync ──────── + + [Fact] + public async Task MarkInstallResultAsync_Success_SetsInstalledStatus() + { + // Arrange + + ComponentRegistration reg = new() + { + Name = "mark-success", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + await sut.PrepareInstallAsync(component.Id); + + // Act + + ClusterComponent result = await sut.MarkInstallResultAsync(component.Id, success: true); + + // Assert + + result.Status.Should().Be(ComponentStatus.Installed); + result.InstalledAt.Should().NotBeNull(); + result.LastError.Should().BeNull(); + } + + [Fact] + public async Task MarkInstallResultAsync_Failure_SetsFailedStatusWithError() + { + // Arrange + + ComponentRegistration reg = new() + { + Name = "mark-failure", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + await sut.PrepareInstallAsync(component.Id); + + // Act + + ClusterComponent result = await sut.MarkInstallResultAsync( + component.Id, success: false, error: "timeout waiting for resources"); + + // Assert + + result.Status.Should().Be(ComponentStatus.Failed); + result.LastError.Should().Contain("timeout"); + result.InstalledAt.Should().BeNull(); + } + + // ──────── PrepareUninstallAsync ──────── + + [Fact] + public async Task PrepareUninstallAsync_InstalledComponent_SetsUninstallingStatus() + { + // Arrange — an installed component ready to be removed. + + ComponentRegistration reg = new() + { + Name = "to-uninstall", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + // Re-fetch from test db since the returned entity is detached. + ClusterComponent tracked = (await db.Set().FindAsync(component.Id))!; + tracked.Status = ComponentStatus.Installed; + tracked.InstalledAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + // Act + + ClusterComponent prepared = await sut.PrepareUninstallAsync(component.Id); + + // Assert + + prepared.Status.Should().Be(ComponentStatus.Uninstalling); + } + + [Fact] + public async Task PrepareUninstallAsync_NotInstalled_Throws() + { + // Arrange — can't uninstall something that's not installed. + + ComponentRegistration reg = new() + { + Name = "not-installed-yet", + ComponentType = "HelmChart", + Namespace = "default", + HelmChartName = "some-chart" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + // Act & Assert + + Func act = () => sut.PrepareUninstallAsync(component.Id); + await act.Should().ThrowAsync() + .WithMessage("*not installed*"); + } + + // ──────── GetInstallCommandAsync — builds helm CLI command ──────── + + [Fact] + public async Task GetInstallCommandAsync_WithAllFields_ReturnsCorrectCommand() + { + // Arrange — a fully configured component. + + ComponentRegistration reg = new() + { + Name = "full-cmd", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmRepoUrl = "https://prometheus-community.github.io/helm-charts", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0", + ReleaseName = "prom-stack" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + await sut.UpdateConfigurationAsync(component.Id, "grafana:\n enabled: true"); + + // Act + + HelmCommand command = await sut.GetInstallCommandAsync(component.Id); + + // Assert — verify the command structure is correct. + + command.Operation.Should().Be("upgrade --install"); + command.ReleaseName.Should().Be("prom-stack"); + command.ChartReference.Should().Contain("kube-prometheus-stack"); + command.Namespace.Should().Be("monitoring"); + command.RepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts"); + command.Version.Should().Be("65.1.0"); + command.HasValues.Should().BeTrue(); + } + + [Fact] + public async Task GetUninstallCommandAsync_InstalledComponent_ReturnsCorrectCommand() + { + // Arrange + + ComponentRegistration reg = new() + { + Name = "uninstall-cmd", + ComponentType = "HelmChart", + Namespace = "monitoring", + HelmChartName = "kube-prometheus-stack", + ReleaseName = "prom-stack" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, reg); + + // Re-fetch from test db since the returned entity is detached. + ClusterComponent tracked = (await db.Set().FindAsync(component.Id))!; + tracked.Status = ComponentStatus.Installed; + await db.SaveChangesAsync(); + + // Act + + HelmCommand command = await sut.GetUninstallCommandAsync(component.Id); + + // Assert + + command.Operation.Should().Be("uninstall"); + command.ReleaseName.Should().Be("prom-stack"); + command.Namespace.Should().Be("monitoring"); + } + + // ──────── Secret Injection ──────── + + [Fact] + public async Task GetInstallCommandAsync_WithVaultSecret_InjectsSecretIntoValues() + { + // Arrange — register kube-prometheus-stack and store a Grafana admin + // password in the vault. The password should NOT be in the plain YAML + // but SHOULD appear in the final install command values. + + ComponentRegistration registration = new() + { + Name = "kube-prometheus-stack", + ComponentType = "Helm", + Namespace = "monitoring", + HelmRepoUrl = "https://prometheus-community.github.io/helm-charts", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0", + ReleaseName = "kube-prometheus-stack", + HelmValues = "grafana:\n enabled: true\n" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, registration); + + // Store the admin password in the vault under the secret name + // matching the catalog's SecretName for grafana-password. + + await vaultService.InitializeVaultAsync(tenantId); + await vaultService.SetComponentSecretAsync(tenantId, component.Id, "GRAFANA_ADMIN_PASSWORD", "SuperSecret123!"); + + await sut.PrepareInstallAsync(component.Id); + + // Act + + HelmCommand command = await sut.GetInstallCommandAsync(component.Id); + + // Assert — the values YAML should contain the injected secret. + + command.ValuesYaml.Should().Contain("SuperSecret123!"); + command.ValuesYaml.Should().Contain("adminPassword"); + command.HasValues.Should().BeTrue(); + } + + [Fact] + public async Task GetInstallCommandAsync_WithoutVaultSecret_DoesNotInjectAnything() + { + // Arrange — register kube-prometheus-stack but DON'T store any secret. + // The values YAML should remain unchanged. + + ComponentRegistration registration = new() + { + Name = "kube-prometheus-stack", + ComponentType = "Helm", + Namespace = "monitoring", + HelmRepoUrl = "https://prometheus-community.github.io/helm-charts", + HelmChartName = "kube-prometheus-stack", + HelmChartVersion = "65.1.0", + ReleaseName = "prom-no-secret", + HelmValues = "grafana:\n enabled: true\n" + }; + + ClusterComponent component = await sut.RegisterComponentAsync(clusterId, registration); + await sut.PrepareInstallAsync(component.Id); + + // Act + + HelmCommand command = await sut.GetInstallCommandAsync(component.Id); + + // Assert — no secret injection means original YAML is preserved. + + command.ValuesYaml.Should().Be("grafana:\n enabled: true\n"); + command.HasValues.Should().BeTrue(); + } +} diff --git a/tests/EntKube.Web.Tests/ComponentScanServiceTests.cs b/tests/EntKube.Web.Tests/ComponentScanServiceTests.cs new file mode 100644 index 0000000..f5c45a6 --- /dev/null +++ b/tests/EntKube.Web.Tests/ComponentScanServiceTests.cs @@ -0,0 +1,395 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for ComponentScanService — verifies Helm release decoding, discovery +/// logic, and import behavior. Uses SQLite in-memory for isolated database tests. +/// +public class ComponentScanServiceTests : IDisposable +{ + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly ComponentScanService sut; + private readonly Guid tenantId = Guid.NewGuid(); + private readonly Guid environmentId = Guid.NewGuid(); + private readonly Guid clusterId = Guid.NewGuid(); + private readonly KubernetesCluster testCluster; + + public ComponentScanServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + VaultEncryptionService encryption = new(TestRootKey); + VaultService vaultService = new(dbFactory, encryption); + sut = new ComponentScanService(dbFactory, vaultService); + + // Seed tenant, environment, and cluster. + + db.Tenants.Add(new Tenant { Id = tenantId, Name = "TestTenant", Slug = "test" }); + db.Environments.Add(new Data.Environment { Id = environmentId, TenantId = tenantId, Name = "dev" }); + + testCluster = new KubernetesCluster + { + Id = clusterId, + TenantId = tenantId, + EnvironmentId = environmentId, + Name = "test-cluster", + ApiServerUrl = "https://k8s.example.com" + }; + + db.KubernetesClusters.Add(testCluster); + db.SaveChanges(); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── DecodeHelmRelease Tests ──────── + + [Fact] + public void DecodeHelmRelease_ValidPayload_ReturnsRelease() + { + // Arrange — create a realistic Helm release JSON, gzip it, base64 it. + + HelmReleaseJson releaseJson = new() + { + Name = "my-app", + Namespace = "production", + Chart = new HelmChartJson + { + Metadata = new HelmChartMetadataJson + { + Name = "nginx", + Version = "1.2.3", + AppVersion = "1.25.0" + } + }, + Info = new HelmInfoJson + { + Status = "deployed", + LastDeployed = "2025-01-15T10:30:00Z" + }, + Config = JsonDocument.Parse("{\"replicaCount\":3,\"image\":{\"tag\":\"latest\"}}").RootElement + }; + + byte[] secretData = EncodeHelmRelease(releaseJson); + + // Act — invoke the private method via the public ScanHelmReleasesAsync, + // but since we can't easily mock K8s client, test the decode logic directly. + + DiscoveredHelmRelease? result = InvokeDecodeHelmRelease(secretData, 5); + + // Assert + + result.Should().NotBeNull(); + result!.Name.Should().Be("my-app"); + result.Namespace.Should().Be("production"); + result.ChartName.Should().Be("nginx"); + result.ChartVersion.Should().Be("1.2.3"); + result.AppVersion.Should().Be("1.25.0"); + result.Status.Should().Be("deployed"); + result.Revision.Should().Be(5); + result.Values.Should().Contain("replicaCount"); + result.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public void DecodeHelmRelease_EmptyConfig_ReturnsNullValues() + { + // Arrange — release with empty config {}. + + HelmReleaseJson releaseJson = new() + { + Name = "minimal", + Namespace = "default", + Chart = new HelmChartJson + { + Metadata = new HelmChartMetadataJson { Name = "redis", Version = "0.1.0" } + }, + Info = new HelmInfoJson { Status = "deployed" }, + Config = JsonDocument.Parse("{}").RootElement + }; + + byte[] secretData = EncodeHelmRelease(releaseJson); + + // Act + + DiscoveredHelmRelease? result = InvokeDecodeHelmRelease(secretData, 1); + + // Assert — empty config means no custom values. + + result.Should().NotBeNull(); + result!.Values.Should().BeNull(); + } + + [Fact] + public void DecodeHelmRelease_InvalidData_ReturnsFallbackFromLabels() + { + // Arrange — garbage data that isn't valid base64 of gzipped content. + + byte[] secretData = Encoding.UTF8.GetBytes("not-valid-base64!!!"); + + // Act — the service gracefully falls back to label-based metadata. + + DiscoveredHelmRelease? result = InvokeDecodeHelmRelease(secretData, 1); + + // Assert — returns a minimal release from secret labels. + + result.Should().NotBeNull(); + result!.Name.Should().Be("test"); + result.Namespace.Should().Be("default"); + result.Revision.Should().Be(1); + } + + // ──────── ImportReleaseAsync Tests ──────── + + [Fact] + public async Task ImportReleaseAsync_NewRelease_CreatesComponent() + { + // Arrange + + DiscoveredHelmRelease release = new() + { + Name = "grafana", + Namespace = "monitoring", + ChartName = "grafana", + ChartVersion = "7.0.0", + Status = "deployed", + Revision = 3, + Values = "{\"persistence\":{\"enabled\":true}}", + UpdatedAt = DateTime.UtcNow + }; + + // Act + + ClusterComponent result = await sut.ImportReleaseAsync(testCluster, release); + + // Assert + + result.Name.Should().Be("grafana"); + result.Namespace.Should().Be("monitoring"); + result.HelmChartName.Should().Be("grafana"); + result.HelmChartVersion.Should().Be("7.0.0"); + result.Status.Should().Be(ComponentStatus.Installed); + result.HelmValues.Should().Contain("persistence"); + result.ClusterId.Should().Be(clusterId); + + // Verify persisted in DB. + + ClusterComponent? persisted = await db.ClusterComponents.FindAsync(result.Id); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task ImportReleaseAsync_ExistingComponent_UpdatesIt() + { + // Arrange — pre-create a component with outdated info. + + ClusterComponent existing = new() + { + Id = Guid.NewGuid(), + ClusterId = clusterId, + Name = "prometheus", + ComponentType = "HelmChart", + Namespace = "old-namespace", + HelmChartVersion = "1.0.0", + Status = ComponentStatus.NotInstalled + }; + db.ClusterComponents.Add(existing); + await db.SaveChangesAsync(); + + DiscoveredHelmRelease release = new() + { + Name = "prometheus", + Namespace = "monitoring", + ChartName = "kube-prometheus-stack", + ChartVersion = "55.0.0", + Status = "deployed", + Revision = 12, + Values = "{\"alertmanager\":{\"enabled\":false}}" + }; + + // Act + + ClusterComponent result = await sut.ImportReleaseAsync(testCluster, release); + + // Assert — should update existing, not create new. + + result.Id.Should().Be(existing.Id); + result.Namespace.Should().Be("monitoring"); + result.HelmChartVersion.Should().Be("55.0.0"); + result.HelmChartName.Should().Be("kube-prometheus-stack"); + result.Status.Should().Be(ComponentStatus.Installed); + + int count = await db.ClusterComponents.CountAsync(c => c.ClusterId == clusterId && c.Name == "prometheus"); + count.Should().Be(1); + } + + [Fact] + public async Task ImportAllNewReleasesAsync_SkipsAlreadyTracked() + { + // Arrange — mix of tracked and new releases. + + List releases = + [ + new() { Name = "existing-one", Namespace = "ns1", Status = "deployed", Revision = 1, AlreadyTracked = true }, + new() { Name = "new-one", Namespace = "ns2", ChartName = "new-chart", ChartVersion = "1.0.0", Status = "deployed", Revision = 1, AlreadyTracked = false }, + new() { Name = "new-two", Namespace = "ns3", ChartName = "other-chart", ChartVersion = "2.0.0", Status = "deployed", Revision = 1, AlreadyTracked = false } + ]; + + // Act + + int imported = await sut.ImportAllNewReleasesAsync(testCluster, releases); + + // Assert — only non-tracked releases should be imported. + + imported.Should().Be(2); + int total = await db.ClusterComponents.CountAsync(c => c.ClusterId == clusterId); + total.Should().Be(2); + } + + // ──────── MapStatus Tests ──────── + + [Theory] + [InlineData("deployed", ComponentStatus.Installed)] + [InlineData("failed", ComponentStatus.Failed)] + [InlineData("pending-install", ComponentStatus.Installing)] + [InlineData("pending-upgrade", ComponentStatus.Installing)] + [InlineData("pending-rollback", ComponentStatus.Installing)] + [InlineData("uninstalling", ComponentStatus.Uninstalling)] + [InlineData("unknown-status", ComponentStatus.NotInstalled)] + [InlineData(null, ComponentStatus.NotInstalled)] + public void MapStatus_MapsHelmStatusToComponentStatus(string? helmStatus, ComponentStatus expected) + { + // Use reflection to test the private static method. + + System.Reflection.MethodInfo? method = typeof(ComponentScanService) + .GetMethod("MapStatus", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + method.Should().NotBeNull(); + + ComponentStatus result = (ComponentStatus)method!.Invoke(null, [helmStatus])!; + + result.Should().Be(expected); + } + + // ──────── Helpers ──────── + + /// + /// Encodes a Helm release JSON object into the format stored in K8s secrets: + /// JSON → gzip → base64 (Helm). The K8s layer handles the outer base64. + /// + private static byte[] EncodeHelmRelease(HelmReleaseJson release) + { + string json = JsonSerializer.Serialize(release, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + byte[] jsonBytes = Encoding.UTF8.GetBytes(json); + + using MemoryStream compressedStream = new(); + using (GZipStream gzipStream = new(compressedStream, CompressionMode.Compress)) + { + gzipStream.Write(jsonBytes); + } + + // Helm base64 encoding. + + string helmBase64 = Convert.ToBase64String(compressedStream.ToArray()); + return Encoding.UTF8.GetBytes(helmBase64); + } + + /// + /// Invokes the private DecodeHelmRelease method via reflection for unit testing. + /// + private static DiscoveredHelmRelease? InvokeDecodeHelmRelease(byte[] secretData, int revision) + { + System.Reflection.MethodInfo? method = typeof(ComponentScanService) + .GetMethod("DecodeHelmRelease", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + if (method is null) + { + throw new InvalidOperationException("DecodeHelmRelease method not found"); + } + + // Build a minimal V1Secret with the release data. + + k8s.Models.V1Secret secret = new() + { + Metadata = new k8s.Models.V1ObjectMeta + { + Name = "sh.helm.release.v1.test.v1", + NamespaceProperty = "default", + Labels = new Dictionary + { + ["name"] = "test", + ["owner"] = "helm", + ["version"] = revision.ToString() + } + }, + Data = new Dictionary + { + ["release"] = secretData + } + }; + + return method.Invoke(null, [secret, revision]) as DiscoveredHelmRelease; + } + + // ──────── Test DTOs for creating Helm release JSON ──────── + + private class HelmReleaseJson + { + public string Name { get; set; } = ""; + public string Namespace { get; set; } = ""; + public HelmChartJson? Chart { get; set; } + public HelmInfoJson? Info { get; set; } + public JsonElement? Config { get; set; } + } + + private class HelmChartJson + { + public HelmChartMetadataJson? Metadata { get; set; } + } + + private class HelmChartMetadataJson + { + public string? Name { get; set; } + public string? Version { get; set; } + public string? AppVersion { get; set; } + } + + private class HelmInfoJson + { + public string? Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("last_deployed")] + public string? LastDeployed { get; set; } + } +} diff --git a/tests/EntKube.Web.Tests/CustomerAccessServiceTests.cs b/tests/EntKube.Web.Tests/CustomerAccessServiceTests.cs new file mode 100644 index 0000000..7361e7d --- /dev/null +++ b/tests/EntKube.Web.Tests/CustomerAccessServiceTests.cs @@ -0,0 +1,236 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the CustomerAccessService which manages user access to specific +/// customers. A tenant admin grants access, and the portal uses this to filter +/// which customers a user can see. +/// +public class CustomerAccessServiceTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly CustomerAccessService sut; + + public CustomerAccessServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + sut = new CustomerAccessService(dbFactory); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ── Helpers ── + + /// + /// Creates a tenant with a customer and an application user — the minimum + /// scaffolding needed to grant customer access. + /// + private (ApplicationUser user, Customer customer, Tenant tenant) CreateUserAndCustomer( + string userName = "alice@example.com", + string customerName = "Contoso") + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = customerName }; + db.Customers.Add(customer); + + ApplicationUser user = new() { Id = Guid.NewGuid().ToString(), UserName = userName, Email = userName }; + db.Users.Add(user); + + db.SaveChanges(); + return (user, customer, tenant); + } + + // ════════════════════════════════════════════════════════════════ + // GrantAccessAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GrantAccessAsync_CreatesAccessWithDefaultViewerRole() + { + // Arrange — we have a user and a customer. + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + + // Act — grant the user access to the customer. + CustomerAccess access = await sut.GrantAccessAsync(user.Id, customer.Id); + + // Assert — access is created with Viewer role by default. + access.UserId.Should().Be(user.Id); + access.CustomerId.Should().Be(customer.Id); + access.Role.Should().Be(CustomerAccessRole.Viewer); + } + + [Fact] + public async Task GrantAccessAsync_WithOperatorRole_StoresRole() + { + // Arrange + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + + // Act + CustomerAccess access = await sut.GrantAccessAsync( + user.Id, customer.Id, CustomerAccessRole.Operator); + + // Assert + access.Role.Should().Be(CustomerAccessRole.Operator); + } + + [Fact] + public async Task GrantAccessAsync_DuplicateGrant_Throws() + { + // Arrange — grant access once. + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + await sut.GrantAccessAsync(user.Id, customer.Id); + + // Act & Assert — granting again should fail (composite key violation). + Func act = () => sut.GrantAccessAsync(user.Id, customer.Id); + await act.Should().ThrowAsync(); + } + + // ════════════════════════════════════════════════════════════════ + // RevokeAccessAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task RevokeAccessAsync_RemovesAccess() + { + // Arrange + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + await sut.GrantAccessAsync(user.Id, customer.Id); + + // Act + await sut.RevokeAccessAsync(user.Id, customer.Id); + + // Assert + List customers = await sut.GetAccessibleCustomersAsync(user.Id); + customers.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════════ + // UpdateRoleAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UpdateRoleAsync_ChangesRole() + { + // Arrange — user starts as Viewer. + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + await sut.GrantAccessAsync(user.Id, customer.Id, CustomerAccessRole.Viewer); + + // Act — promote to Admin. + await sut.UpdateRoleAsync(user.Id, customer.Id, CustomerAccessRole.Admin); + + // Assert + CustomerAccess? access = await sut.GetAccessAsync(user.Id, customer.Id); + access.Should().NotBeNull(); + access!.Role.Should().Be(CustomerAccessRole.Admin); + } + + // ════════════════════════════════════════════════════════════════ + // GetAccessibleCustomersAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetAccessibleCustomersAsync_ReturnsOnlyGrantedCustomers() + { + // Arrange — user has access to Contoso but not Fabrikam. + (ApplicationUser user, Customer contoso, Tenant tenant) = CreateUserAndCustomer(); + + Customer fabrikam = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Fabrikam" }; + db.Customers.Add(fabrikam); + db.SaveChanges(); + + await sut.GrantAccessAsync(user.Id, contoso.Id); + + // Act + List customers = await sut.GetAccessibleCustomersAsync(user.Id); + + // Assert — only Contoso, not Fabrikam. + customers.Should().HaveCount(1); + customers[0].Name.Should().Be("Contoso"); + } + + [Fact] + public async Task GetAccessibleCustomersAsync_IncludesApps() + { + // Arrange — customer with an app. + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" }; + db.Apps.Add(app); + db.SaveChanges(); + + await sut.GrantAccessAsync(user.Id, customer.Id); + + // Act + List customers = await sut.GetAccessibleCustomersAsync(user.Id); + + // Assert — the customer includes its apps. + customers[0].Apps.Should().HaveCount(1); + customers[0].Apps.First().Name.Should().Be("billing-api"); + } + + // ════════════════════════════════════════════════════════════════ + // GetAccessAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetAccessAsync_ReturnsNullWhenNoAccess() + { + // Arrange + (ApplicationUser user, Customer customer, _) = CreateUserAndCustomer(); + + // Act — no access has been granted. + CustomerAccess? access = await sut.GetAccessAsync(user.Id, customer.Id); + + // Assert + access.Should().BeNull(); + } + + // ════════════════════════════════════════════════════════════════ + // GetCustomerUsersAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetCustomerUsersAsync_ReturnsAllUsersWithAccess() + { + // Arrange — two users with access to the same customer. + (ApplicationUser alice, Customer customer, _) = CreateUserAndCustomer("alice@example.com"); + + ApplicationUser bob = new() { Id = Guid.NewGuid().ToString(), UserName = "bob@example.com", Email = "bob@example.com" }; + db.Users.Add(bob); + db.SaveChanges(); + + await sut.GrantAccessAsync(alice.Id, customer.Id, CustomerAccessRole.Admin); + await sut.GrantAccessAsync(bob.Id, customer.Id, CustomerAccessRole.Viewer); + + // Act + List accesses = await sut.GetCustomerUsersAsync(customer.Id); + + // Assert + accesses.Should().HaveCount(2); + accesses.Should().Contain(a => a.User.UserName == "alice@example.com" && a.Role == CustomerAccessRole.Admin); + accesses.Should().Contain(a => a.User.UserName == "bob@example.com" && a.Role == CustomerAccessRole.Viewer); + } +} diff --git a/tests/EntKube.Web.Tests/CustomerEntityTests.cs b/tests/EntKube.Web.Tests/CustomerEntityTests.cs new file mode 100644 index 0000000..cc8604a --- /dev/null +++ b/tests/EntKube.Web.Tests/CustomerEntityTests.cs @@ -0,0 +1,119 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// A customer represents an end-client or account within a tenant. Tenants +/// may serve multiple customers, and this entity tracks that relationship. +/// +public class CustomerEntityTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public CustomerEntityTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task Customer_CanBeCreated_WithinTenant() + { + // A customer belongs to a tenant. It represents an end-client + // or account that the tenant organization serves. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Customer customer = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Big Corp" + }; + + context.Customers.Add(customer); + await context.SaveChangesAsync(); + + Customer? retrieved = await context.Customers + .Include(c => c.Tenant) + .FirstOrDefaultAsync(c => c.Name == "Big Corp"); + + retrieved.Should().NotBeNull(); + retrieved!.Tenant.Name.Should().Be("Acme"); + } + + [Fact] + public async Task Customer_NameMustBeUniqueWithinTenant() + { + // Two customers in the same tenant cannot share the same name. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + context.Customers.Add(new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }); + await context.SaveChangesAsync(); + + context.Customers.Add(new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Big Corp" }); + Func act = async () => await context.SaveChangesAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Customer_SameNameAllowedInDifferentTenants() + { + // Different tenants can each have a customer named "Big Corp". + + Tenant tenantA = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + Tenant tenantB = new() { Id = Guid.NewGuid(), Name = "Beta", Slug = "beta" }; + context.Tenants.AddRange(tenantA, tenantB); + + context.Customers.AddRange( + new Customer { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = "Big Corp" }, + new Customer { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = "Big Corp" } + ); + + await context.SaveChangesAsync(); + + List customers = await context.Customers.Where(c => c.Name == "Big Corp").ToListAsync(); + customers.Should().HaveCount(2); + } + + [Fact] + public async Task Tenant_CanHaveMultipleCustomers() + { + // A tenant typically serves several customers. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + context.Customers.AddRange( + new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Customer A" }, + new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Customer B" }, + new Customer { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Customer C" } + ); + + await context.SaveChangesAsync(); + + List customers = await context.Customers.Where(c => c.TenantId == tenant.Id).ToListAsync(); + customers.Should().HaveCount(3); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/DeploymentServiceTests.cs b/tests/EntKube.Web.Tests/DeploymentServiceTests.cs new file mode 100644 index 0000000..dc3f401 --- /dev/null +++ b/tests/EntKube.Web.Tests/DeploymentServiceTests.cs @@ -0,0 +1,417 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the DeploymentService which manages app deployments — creating +/// deployments (Manual, Yaml, Helm), managing manifests, and tracking status. +/// Uses SQLite in-memory for fast, isolated database tests. +/// +public class DeploymentServiceTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly DeploymentService sut; + + public DeploymentServiceTests() + { + // Stand up a fresh in-memory SQLite database for each test. + // This gives us real EF Core behavior without touching disk. + + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + sut = new DeploymentService(dbFactory); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ── Helpers ── + + /// + /// Sets up a tenant with an environment, cluster, customer, and app — the + /// minimum scaffolding needed to create a deployment. + /// + private (App app, Data.Environment env, KubernetesCluster cluster) CreateTestApp() + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "production" }; + db.Environments.Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com" + }; + db.KubernetesClusters.Add(cluster); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Contoso" }; + db.Customers.Add(customer); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" }; + db.Apps.Add(app); + + db.SaveChanges(); + return (app, env, cluster); + } + + // ════════════════════════════════════════════════════════════════ + // CreateDeploymentAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task CreateDeploymentAsync_ManualType_CreatesWithUnknownStatus() + { + // Arrange — we need an app, environment, and cluster to target. + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + // Act — create a manual deployment targeting the prod cluster. + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "billing-deploy", DeploymentType.Manual, + env.Id, cluster.Id, "billing-ns"); + + // Assert — the deployment exists with the right defaults. + deployment.Id.Should().NotBeEmpty(); + deployment.Name.Should().Be("billing-deploy"); + deployment.Type.Should().Be(DeploymentType.Manual); + deployment.Namespace.Should().Be("billing-ns"); + deployment.SyncStatus.Should().Be(SyncStatus.Unknown); + deployment.HealthStatus.Should().Be(HealthStatus.Unknown); + } + + [Fact] + public async Task CreateDeploymentAsync_HelmType_StoresChartInfo() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + // Act — create a Helm deployment with chart details. + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "minio-deploy", DeploymentType.HelmChart, + env.Id, cluster.Id, "minio-ns", + helmRepoUrl: "https://charts.min.io", + helmChartName: "minio", + helmChartVersion: "5.0.0"); + + // Assert + deployment.Type.Should().Be(DeploymentType.HelmChart); + deployment.HelmRepoUrl.Should().Be("https://charts.min.io"); + deployment.HelmChartName.Should().Be("minio"); + deployment.HelmChartVersion.Should().Be("5.0.0"); + } + + [Fact] + public async Task CreateDeploymentAsync_DuplicateName_Throws() + { + // Arrange — create a deployment, then try to create another with the same name. + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + await sut.CreateDeploymentAsync( + app.Id, "billing-deploy", DeploymentType.Manual, + env.Id, cluster.Id, "billing-ns"); + + // Act & Assert + Func act = () => sut.CreateDeploymentAsync( + app.Id, "billing-deploy", DeploymentType.Manual, + env.Id, cluster.Id, "other-ns"); + + await act.Should().ThrowAsync(); + } + + // ════════════════════════════════════════════════════════════════ + // GetDeploymentsAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetDeploymentsAsync_ReturnsDeploymentsForApp() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + await sut.CreateDeploymentAsync(app.Id, "deploy-1", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + await sut.CreateDeploymentAsync(app.Id, "deploy-2", DeploymentType.HelmChart, env.Id, cluster.Id, "ns2"); + + // Act + List deployments = await sut.GetDeploymentsAsync(app.Id); + + // Assert — both deployments returned with related data loaded. + deployments.Should().HaveCount(2); + deployments.Should().Contain(d => d.Name == "deploy-1"); + deployments.Should().Contain(d => d.Name == "deploy-2"); + } + + [Fact] + public async Task GetDeploymentsAsync_IncludesEnvironmentAndCluster() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + await sut.CreateDeploymentAsync(app.Id, "deploy-1", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + + // Act + List deployments = await sut.GetDeploymentsAsync(app.Id); + + // Assert — navigation properties should be loaded. + deployments[0].Environment.Name.Should().Be("production"); + deployments[0].Cluster.Name.Should().Be("prod-cluster"); + } + + // ════════════════════════════════════════════════════════════════ + // DeleteDeploymentAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeleteDeploymentAsync_RemovesDeployment() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "to-delete", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + + // Act + await sut.DeleteDeploymentAsync(deployment.Id); + + // Assert + List remaining = await sut.GetDeploymentsAsync(app.Id); + remaining.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════════ + // Manifest CRUD + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task AddManifestAsync_CreatesManifest() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "yaml-deploy", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); + + string yaml = "apiVersion: v1\nkind: Service\nmetadata:\n name: billing-svc"; + + // Act — add a manifest to the deployment. + DeploymentManifest manifest = await sut.AddManifestAsync( + deployment.Id, "Service", "billing-svc", yaml, sortOrder: 1); + + // Assert + manifest.Id.Should().NotBeEmpty(); + manifest.Kind.Should().Be("Service"); + manifest.Name.Should().Be("billing-svc"); + manifest.YamlContent.Should().Be(yaml); + } + + [Fact] + public async Task GetManifestsAsync_ReturnsOrderedManifests() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "ordered-deploy", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); + + await sut.AddManifestAsync(deployment.Id, "Service", "svc", "svc-yaml", sortOrder: 2); + await sut.AddManifestAsync(deployment.Id, "Deployment", "dep", "dep-yaml", sortOrder: 1); + await sut.AddManifestAsync(deployment.Id, "PersistentVolumeClaim", "pvc", "pvc-yaml", sortOrder: 0); + + // Act + List manifests = await sut.GetManifestsAsync(deployment.Id); + + // Assert — returned in SortOrder (PVC → Deployment → Service). + manifests.Should().HaveCount(3); + manifests[0].Kind.Should().Be("PersistentVolumeClaim"); + manifests[1].Kind.Should().Be("Deployment"); + manifests[2].Kind.Should().Be("Service"); + } + + [Fact] + public async Task UpdateManifestAsync_UpdatesYamlContent() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "update-test", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); + + DeploymentManifest manifest = await sut.AddManifestAsync( + deployment.Id, "Deployment", "billing", "old-yaml", sortOrder: 0); + + // Act + await sut.UpdateManifestAsync(manifest.Id, "new-yaml-content"); + + // Assert + List manifests = await sut.GetManifestsAsync(deployment.Id); + manifests[0].YamlContent.Should().Be("new-yaml-content"); + } + + [Fact] + public async Task DeleteManifestAsync_RemovesManifest() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "del-manifest", DeploymentType.Yaml, env.Id, cluster.Id, "ns1"); + + DeploymentManifest manifest = await sut.AddManifestAsync( + deployment.Id, "Service", "svc", "yaml", sortOrder: 0); + + // Act + await sut.DeleteManifestAsync(manifest.Id); + + // Assert + List remaining = await sut.GetManifestsAsync(deployment.Id); + remaining.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════════ + // Helm Values + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UpdateHelmValuesAsync_StoresYaml() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "helm-deploy", DeploymentType.HelmChart, env.Id, cluster.Id, "minio-ns", + helmRepoUrl: "https://charts.min.io", helmChartName: "minio", helmChartVersion: "5.0.0"); + + string values = "replicas: 3\npersistence:\n size: 10Gi"; + + // Act + await sut.UpdateHelmValuesAsync(deployment.Id, values); + + // Assert — reload from DB to verify persistence. + List deployments = await sut.GetDeploymentsAsync(app.Id); + deployments[0].HelmValues.Should().Be(values); + } + + // ════════════════════════════════════════════════════════════════ + // Deployment Resources (ArgoCD-style tree) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UpsertResourceAsync_CreatesNewResource() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "res-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + + // Act — the cluster watcher reports a Deployment resource. + DeploymentResource resource = await sut.UpsertResourceAsync( + deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", + SyncStatus.Synced, HealthStatus.Healthy, "Running 3/3 replicas"); + + // Assert + resource.Id.Should().NotBeEmpty(); + resource.Kind.Should().Be("Deployment"); + resource.SyncStatus.Should().Be(SyncStatus.Synced); + resource.HealthStatus.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task UpsertResourceAsync_UpdatesExistingResource() + { + // Arrange — create a resource, then update its health. + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "upsert-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + + await sut.UpsertResourceAsync( + deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", + SyncStatus.Synced, HealthStatus.Progressing, "Rolling update"); + + // Act — same resource, updated status. + await sut.UpsertResourceAsync( + deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", + SyncStatus.Synced, HealthStatus.Healthy, "Running 3/3"); + + // Assert — only one resource, with the updated health. + List resources = await sut.GetResourceTreeAsync(deployment.Id); + resources.Should().HaveCount(1); + resources[0].HealthStatus.Should().Be(HealthStatus.Healthy); + resources[0].StatusMessage.Should().Be("Running 3/3"); + } + + [Fact] + public async Task GetResourceTreeAsync_ReturnsOnlyRootResources() + { + // Arrange — build a two-level tree: Deployment → ReplicaSet. + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "tree-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + + DeploymentResource parent = await sut.UpsertResourceAsync( + deployment.Id, "apps", "v1", "Deployment", "billing-api", "ns1", + SyncStatus.Synced, HealthStatus.Healthy, null); + + await sut.UpsertResourceAsync( + deployment.Id, "apps", "v1", "ReplicaSet", "billing-api-abc123", "ns1", + SyncStatus.Synced, HealthStatus.Healthy, null, + parentResourceId: parent.Id); + + // Act — get the tree roots. + List roots = await sut.GetResourceTreeAsync(deployment.Id); + + // Assert — only the Deployment root, with the ReplicaSet as a child. + roots.Should().HaveCount(1); + roots[0].Kind.Should().Be("Deployment"); + roots[0].ChildResources.Should().HaveCount(1); + roots[0].ChildResources.First().Kind.Should().Be("ReplicaSet"); + } + + // ════════════════════════════════════════════════════════════════ + // UpdateDeploymentStatusAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UpdateDeploymentStatusAsync_UpdatesStatusFields() + { + // Arrange + (App app, Data.Environment env, KubernetesCluster cluster) = CreateTestApp(); + + AppDeployment deployment = await sut.CreateDeploymentAsync( + app.Id, "status-test", DeploymentType.Manual, env.Id, cluster.Id, "ns1"); + + // Act — simulate a sync completion. + await sut.UpdateDeploymentStatusAsync( + deployment.Id, SyncStatus.Synced, HealthStatus.Healthy, "All resources healthy"); + + // Assert + List deployments = await sut.GetDeploymentsAsync(app.Id); + deployments[0].SyncStatus.Should().Be(SyncStatus.Synced); + deployments[0].HealthStatus.Should().Be(HealthStatus.Healthy); + deployments[0].StatusMessage.Should().Be("All resources healthy"); + deployments[0].LastSyncedAt.Should().NotBeNull(); + } +} diff --git a/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj b/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj index 6c2a23d..3bf6ad4 100644 --- a/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj +++ b/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj @@ -9,7 +9,9 @@ - + + + diff --git a/tests/EntKube.Web.Tests/EnvironmentEntityTests.cs b/tests/EntKube.Web.Tests/EnvironmentEntityTests.cs new file mode 100644 index 0000000..52679ea --- /dev/null +++ b/tests/EntKube.Web.Tests/EnvironmentEntityTests.cs @@ -0,0 +1,123 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// An environment represents a deployment stage within a tenant (e.g. "dev", +/// "staging", "production"). Resources like clusters and services are scoped +/// to an environment within a tenant. +/// +public class EnvironmentEntityTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public EnvironmentEntityTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task Environment_CanBeCreated_WithinTenant() + { + // Each tenant has its own set of environments that represent + // deployment stages. An environment has a name and belongs + // to exactly one tenant. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Data.Environment environment = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Production" + }; + + context.Environments.Add(environment); + await context.SaveChangesAsync(); + + Data.Environment? retrieved = await context.Environments + .Include(e => e.Tenant) + .FirstOrDefaultAsync(e => e.Name == "Production"); + + retrieved.Should().NotBeNull(); + retrieved!.Tenant.Name.Should().Be("Acme"); + } + + [Fact] + public async Task Environment_NameMustBeUniqueWithinTenant() + { + // Two environments in the same tenant cannot share the same name — + // you can't have two "Production" environments in one organization. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + context.Environments.Add(new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }); + await context.SaveChangesAsync(); + + context.Environments.Add(new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" }); + Func act = async () => await context.SaveChangesAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Environment_SameNameAllowedInDifferentTenants() + { + // Different tenants can each have a "Production" environment — + // uniqueness is scoped to the tenant, not globally. + + Tenant tenantA = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + Tenant tenantB = new() { Id = Guid.NewGuid(), Name = "Beta", Slug = "beta" }; + context.Tenants.AddRange(tenantA, tenantB); + + context.Environments.AddRange( + new Data.Environment { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = "Production" }, + new Data.Environment { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = "Production" } + ); + + await context.SaveChangesAsync(); + + List envs = await context.Environments.Where(e => e.Name == "Production").ToListAsync(); + envs.Should().HaveCount(2); + } + + [Fact] + public async Task Tenant_CanHaveMultipleEnvironments() + { + // A typical tenant might have dev, staging, and production environments. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + context.Environments.AddRange( + new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Development" }, + new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Staging" }, + new Data.Environment { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Production" } + ); + + await context.SaveChangesAsync(); + + List envs = await context.Environments.Where(e => e.TenantId == tenant.Id).ToListAsync(); + envs.Should().HaveCount(3); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/ExternalRouteServiceTests.cs b/tests/EntKube.Web.Tests/ExternalRouteServiceTests.cs new file mode 100644 index 0000000..5923980 --- /dev/null +++ b/tests/EntKube.Web.Tests/ExternalRouteServiceTests.cs @@ -0,0 +1,385 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for ExternalRouteService — manages exposing components externally +/// via Gateway API HTTPRoutes. Covers route creation, validation, duplicate +/// detection, YAML generation, and gateway resolution. +/// +public class ExternalRouteServiceTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly ExternalRouteService sut; + private readonly Guid clusterId = Guid.NewGuid(); + private readonly Guid componentId = Guid.NewGuid(); + + public ExternalRouteServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + // Seed a cluster with traefik installed and a monitoring component. + + Guid tenantId = Guid.NewGuid(); + Guid envId = Guid.NewGuid(); + Tenant tenant = new() { Id = tenantId, Name = "RouteTenant", Slug = "route" }; + Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" }; + KubernetesCluster cluster = new() + { + Id = clusterId, + TenantId = tenantId, + EnvironmentId = envId, + Name = "route-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = "apiVersion: v1\nkind: Config" + }; + + ClusterComponent traefik = new() + { + Id = Guid.NewGuid(), + ClusterId = clusterId, + Name = "traefik", + ComponentType = "HelmChart", + Namespace = "traefik", + Status = ComponentStatus.Installed + }; + + ClusterComponent monitoring = new() + { + Id = componentId, + ClusterId = clusterId, + Name = "kube-prometheus-stack", + ComponentType = "HelmChart", + Namespace = "monitoring", + ReleaseName = "kube-prometheus-stack", + Status = ComponentStatus.Installed + }; + + db.Set().Add(tenant); + db.Set().Add(env); + db.KubernetesClusters.Add(cluster); + db.ClusterComponents.AddRange(traefik, monitoring); + db.SaveChanges(); + + sut = new ExternalRouteService(dbFactory); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── Route creation ──────── + + [Fact] + public async Task AddRoute_WithClusterIssuer_CreatesRoute() + { + // The simplest happy path — expose Grafana with Let's Encrypt. + + ExternalRouteRequest request = new() + { + Hostname = "grafana.example.com", + ServiceName = "kube-prometheus-stack-grafana", + ServicePort = 80, + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + + route.Hostname.Should().Be("grafana.example.com"); + route.ServiceName.Should().Be("kube-prometheus-stack-grafana"); + route.ServicePort.Should().Be(80); + route.TlsMode.Should().Be(TlsMode.ClusterIssuer); + route.ClusterIssuerName.Should().Be("letsencrypt-prod"); + route.GatewayName.Should().Be("traefik-gateway"); + route.GatewayNamespace.Should().Be("traefik"); + } + + [Fact] + public async Task AddRoute_WithManualTls_CreatesRoute() + { + // Manual TLS — operator provides their own certificate. + + ExternalRouteRequest request = new() + { + Hostname = "prometheus.example.com", + ServicePort = 9090, + TlsMode = TlsMode.Manual, + TlsCertificate = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + TlsPrivateKey = "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + + route.TlsMode.Should().Be(TlsMode.Manual); + route.TlsCertificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); + route.TlsPrivateKey.Should().StartWith("-----BEGIN PRIVATE KEY-----"); + } + + [Fact] + public async Task AddRoute_DefaultsServiceNameFromComponent() + { + // When no service name is specified, use the component's release name. + + ExternalRouteRequest request = new() + { + Hostname = "alerts.example.com", + ServicePort = 9093, + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + + route.ServiceName.Should().Be("kube-prometheus-stack"); + } + + // ──────── Validation ──────── + + [Fact] + public async Task AddRoute_EmptyHostname_Throws() + { + ExternalRouteRequest request = new() + { + Hostname = " ", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + Func act = () => sut.AddRouteAsync(componentId, request); + + await act.Should().ThrowAsync() + .WithMessage("*Hostname is required*"); + } + + [Fact] + public async Task AddRoute_ClusterIssuer_MissingIssuerName_Throws() + { + ExternalRouteRequest request = new() + { + Hostname = "app.example.com", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = null + }; + + Func act = () => sut.AddRouteAsync(componentId, request); + + await act.Should().ThrowAsync() + .WithMessage("*ClusterIssuer name is required*"); + } + + [Fact] + public async Task AddRoute_ManualTls_MissingCert_Throws() + { + ExternalRouteRequest request = new() + { + Hostname = "app.example.com", + TlsMode = TlsMode.Manual, + TlsCertificate = null + }; + + Func act = () => sut.AddRouteAsync(componentId, request); + + await act.Should().ThrowAsync() + .WithMessage("*TLS certificate is required*"); + } + + [Fact] + public async Task AddRoute_DuplicateHostname_Throws() + { + // Can't use the same hostname twice on the same cluster. + + ExternalRouteRequest request = new() + { + Hostname = "unique.example.com", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + await sut.AddRouteAsync(componentId, request); + + Func duplicate = () => sut.AddRouteAsync(componentId, request); + + await duplicate.Should().ThrowAsync() + .WithMessage("*already in use*"); + } + + // ──────── Route retrieval and deletion ──────── + + [Fact] + public async Task GetRoutes_ReturnsComponentRoutes() + { + ExternalRouteRequest request1 = new() + { + Hostname = "a.example.com", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + ExternalRouteRequest request2 = new() + { + Hostname = "b.example.com", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + await sut.AddRouteAsync(componentId, request1); + await sut.AddRouteAsync(componentId, request2); + + List routes = await sut.GetRoutesAsync(componentId); + + routes.Should().HaveCount(2); + routes.Select(r => r.Hostname).Should().BeEquivalentTo(["a.example.com", "b.example.com"]); + } + + [Fact] + public async Task DeleteRoute_RemovesRoute() + { + ExternalRouteRequest request = new() + { + Hostname = "deleteme.example.com", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + await sut.DeleteRouteAsync(route.Id); + + List routes = await sut.GetRoutesAsync(componentId); + routes.Should().BeEmpty(); + } + + // ──────── YAML generation ──────── + + [Fact] + public async Task GenerateHttpRouteYaml_ClusterIssuer_IncludesAnnotation() + { + ExternalRouteRequest request = new() + { + Hostname = "grafana.example.com", + ServiceName = "kube-prometheus-stack-grafana", + ServicePort = 80, + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + string yaml = await sut.GenerateHttpRouteYamlAsync(route.Id); + + yaml.Should().Contain("kind: HTTPRoute"); + yaml.Should().Contain("grafana.example.com"); + yaml.Should().Contain("cert-manager.io/cluster-issuer: \"letsencrypt-prod\""); + yaml.Should().Contain("name: traefik-gateway"); + yaml.Should().Contain("kube-prometheus-stack-grafana"); + } + + [Fact] + public async Task GenerateHttpRouteYaml_ManualTls_ReferencesSecret() + { + ExternalRouteRequest request = new() + { + Hostname = "manual.example.com", + ServiceName = "my-service", + ServicePort = 443, + TlsMode = TlsMode.Manual, + TlsCertificate = "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + string yaml = await sut.GenerateHttpRouteYamlAsync(route.Id); + + yaml.Should().Contain("kind: HTTPRoute"); + yaml.Should().Contain("manual.example.com"); + yaml.Should().Contain("my-service-tls"); + yaml.Should().Contain("kind: Secret"); + } + + [Fact] + public void GenerateTlsSecretYaml_ManualMode_GeneratesSecret() + { + ExternalRoute route = new() + { + Id = Guid.NewGuid(), + ComponentId = componentId, + Hostname = "secure.example.com", + ServiceName = "my-svc", + ServicePort = 443, + TlsMode = TlsMode.Manual, + TlsCertificate = "CERT_DATA", + TlsPrivateKey = "KEY_DATA", + Component = new ClusterComponent + { + Id = componentId, + ClusterId = clusterId, + Name = "test", + ComponentType = "HelmChart", + Namespace = "apps" + } + }; + + string yaml = ExternalRouteService.GenerateTlsSecretYaml(route); + + yaml.Should().Contain("kind: Secret"); + yaml.Should().Contain("type: kubernetes.io/tls"); + yaml.Should().Contain("namespace: apps"); + yaml.Should().Contain("my-svc-tls"); + } + + [Fact] + public void GenerateTlsSecretYaml_ClusterIssuerMode_ReturnsEmpty() + { + // No Secret needed for automatic TLS — cert-manager handles it. + + ExternalRoute route = new() + { + Id = Guid.NewGuid(), + ComponentId = componentId, + Hostname = "auto.example.com", + ServiceName = "svc", + ServicePort = 80, + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + string yaml = ExternalRouteService.GenerateTlsSecretYaml(route); + + yaml.Should().BeEmpty(); + } + + // ──────── Gateway resolution ──────── + + [Fact] + public async Task AddRoute_ResolvesTraefikGateway() + { + // Cluster has Traefik installed, so gateway should resolve to traefik-gateway. + + ExternalRouteRequest request = new() + { + Hostname = "gw-test.example.com", + TlsMode = TlsMode.ClusterIssuer, + ClusterIssuerName = "letsencrypt-prod" + }; + + ExternalRoute route = await sut.AddRouteAsync(componentId, request); + + route.GatewayName.Should().Be("traefik-gateway"); + route.GatewayNamespace.Should().Be("traefik"); + } +} diff --git a/tests/EntKube.Web.Tests/GroupEntityTests.cs b/tests/EntKube.Web.Tests/GroupEntityTests.cs new file mode 100644 index 0000000..32989b2 --- /dev/null +++ b/tests/EntKube.Web.Tests/GroupEntityTests.cs @@ -0,0 +1,152 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Groups provide a way to organize users within a tenant. A group belongs +/// to exactly one tenant and can contain multiple users. This enables bulk +/// permission assignment and logical grouping of team members. +/// +public class GroupEntityTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public GroupEntityTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task Group_CanBeCreated_WithinTenant() + { + // A group lives within a tenant's boundary. It has a name and belongs + // to exactly one tenant — you can't share groups across tenants. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Group group = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Engineering" + }; + + context.Groups.Add(group); + await context.SaveChangesAsync(); + + Group? retrieved = await context.Groups + .Include(g => g.Tenant) + .FirstOrDefaultAsync(g => g.Name == "Engineering"); + + retrieved.Should().NotBeNull(); + retrieved!.Tenant.Name.Should().Be("Acme"); + } + + [Fact] + public async Task Group_CanHaveMultipleMembers() + { + // Users are added to groups through a membership join entity. + // Multiple users can belong to the same group. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Group group = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Engineering" }; + context.Groups.Add(group); + + ApplicationUser userA = new() + { + Id = Guid.NewGuid().ToString(), + UserName = "alice@acme.com", + NormalizedUserName = "ALICE@ACME.COM", + Email = "alice@acme.com", + NormalizedEmail = "ALICE@ACME.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString() + }; + + ApplicationUser userB = new() + { + Id = Guid.NewGuid().ToString(), + UserName = "bob@acme.com", + NormalizedUserName = "BOB@ACME.COM", + Email = "bob@acme.com", + NormalizedEmail = "BOB@ACME.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString() + }; + + context.Users.AddRange(userA, userB); + + context.GroupMemberships.AddRange( + new GroupMembership { UserId = userA.Id, GroupId = group.Id }, + new GroupMembership { UserId = userB.Id, GroupId = group.Id } + ); + + await context.SaveChangesAsync(); + + List members = await context.GroupMemberships + .Where(gm => gm.GroupId == group.Id) + .ToListAsync(); + + members.Should().HaveCount(2); + } + + [Fact] + public async Task User_CanBelongToMultipleGroups() + { + // A user might be in "Engineering" and "On-Call" simultaneously. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + Group groupA = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Engineering" }; + Group groupB = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "On-Call" }; + context.Groups.AddRange(groupA, groupB); + + ApplicationUser user = new() + { + Id = Guid.NewGuid().ToString(), + UserName = "alice@acme.com", + NormalizedUserName = "ALICE@ACME.COM", + Email = "alice@acme.com", + NormalizedEmail = "ALICE@ACME.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString() + }; + + context.Users.Add(user); + + context.GroupMemberships.AddRange( + new GroupMembership { UserId = user.Id, GroupId = groupA.Id }, + new GroupMembership { UserId = user.Id, GroupId = groupB.Id } + ); + + await context.SaveChangesAsync(); + + List memberships = await context.GroupMemberships + .Where(gm => gm.UserId == user.Id) + .ToListAsync(); + + memberships.Should().HaveCount(2); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/KubeconfigParserTests.cs b/tests/EntKube.Web.Tests/KubeconfigParserTests.cs new file mode 100644 index 0000000..9c82438 --- /dev/null +++ b/tests/EntKube.Web.Tests/KubeconfigParserTests.cs @@ -0,0 +1,155 @@ +using EntKube.Web.Services; +using FluentAssertions; + +namespace EntKube.Web.Tests; + +/// +/// A kubeconfig file contains clusters, users, and contexts. When a user pastes +/// or uploads a kubeconfig, we need to parse it and present the available contexts +/// so they can choose which cluster to register. +/// +public class KubeconfigParserTests +{ + private const string SingleContextKubeconfig = """ + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: https://k8s.prod.example.com:6443 + certificate-authority-data: LS0tLS1C... + name: prod-cluster + contexts: + - context: + cluster: prod-cluster + user: admin-user + namespace: default + name: prod-context + current-context: prod-context + users: + - name: admin-user + user: + token: secret-token-123 + """; + + private const string MultiContextKubeconfig = """ + apiVersion: v1 + kind: Config + clusters: + - cluster: + server: https://k8s.dev.example.com:6443 + name: dev-cluster + - cluster: + server: https://k8s.staging.example.com:6443 + name: staging-cluster + - cluster: + server: https://k8s.prod.example.com:6443 + name: prod-cluster + contexts: + - context: + cluster: dev-cluster + user: dev-user + name: dev + - context: + cluster: staging-cluster + user: staging-user + name: staging + - context: + cluster: prod-cluster + user: prod-user + name: production + current-context: dev + users: + - name: dev-user + user: + token: dev-token + - name: staging-user + user: + token: staging-token + - name: prod-user + user: + token: prod-token + """; + + [Fact] + public void ParseContexts_SingleContext_ReturnsOneEntry() + { + // A kubeconfig with one context should return that context + // with its name and the referenced cluster's server URL. + + List contexts = KubeconfigParser.ParseContexts(SingleContextKubeconfig); + + contexts.Should().HaveCount(1); + contexts[0].Name.Should().Be("prod-context"); + contexts[0].ClusterServer.Should().Be("https://k8s.prod.example.com:6443"); + } + + [Fact] + public void ParseContexts_MultipleContexts_ReturnsAll() + { + // When multiple contexts exist, the user must choose. + + List contexts = KubeconfigParser.ParseContexts(MultiContextKubeconfig); + + contexts.Should().HaveCount(3); + contexts.Select(c => c.Name).Should().Contain(["dev", "staging", "production"]); + } + + [Fact] + public void ParseContexts_ResolvesServerUrlFromClusterReference() + { + // Each context references a cluster by name — we resolve that + // to the actual server URL so the user sees something meaningful. + + List contexts = KubeconfigParser.ParseContexts(MultiContextKubeconfig); + + KubeconfigContext dev = contexts.First(c => c.Name == "dev"); + dev.ClusterServer.Should().Be("https://k8s.dev.example.com:6443"); + + KubeconfigContext prod = contexts.First(c => c.Name == "production"); + prod.ClusterServer.Should().Be("https://k8s.prod.example.com:6443"); + } + + [Fact] + public void ParseContexts_IdentifiesCurrentContext() + { + // The current-context field tells us which context is active by default. + + List contexts = KubeconfigParser.ParseContexts(MultiContextKubeconfig); + + contexts.First(c => c.Name == "dev").IsCurrent.Should().BeTrue(); + contexts.First(c => c.Name == "staging").IsCurrent.Should().BeFalse(); + } + + [Fact] + public void ParseContexts_EmptyOrInvalidYaml_ReturnsEmptyList() + { + // Invalid input should not throw — just return empty. + + KubeconfigParser.ParseContexts("").Should().BeEmpty(); + KubeconfigParser.ParseContexts("not: valid: kubeconfig").Should().BeEmpty(); + KubeconfigParser.ParseContexts("random text").Should().BeEmpty(); + } + + [Fact] + public void ParseContexts_MissingClusterReference_SkipsContext() + { + // If a context references a cluster that doesn't exist in the file, skip it. + + string brokenConfig = """ + apiVersion: v1 + kind: Config + clusters: [] + contexts: + - context: + cluster: nonexistent + user: someone + name: orphan-context + current-context: orphan-context + users: [] + """; + + List contexts = KubeconfigParser.ParseContexts(brokenConfig); + + contexts.Should().BeEmpty(); + } +} diff --git a/tests/EntKube.Web.Tests/KubernetesOperationsServiceTests.cs b/tests/EntKube.Web.Tests/KubernetesOperationsServiceTests.cs new file mode 100644 index 0000000..328ee68 --- /dev/null +++ b/tests/EntKube.Web.Tests/KubernetesOperationsServiceTests.cs @@ -0,0 +1,220 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for KubernetesOperationsService — the service that performs live +/// cluster operations (pod logs, restart, redeploy) using stored kubeconfig. +/// +/// These tests verify the service's data-layer behavior (looking up clusters, +/// building client configs). Actual K8s API calls are integration-level and +/// require a live cluster. We test the plumbing, not the wire. +/// +public class KubernetesOperationsServiceTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly KubernetesOperationsService sut; + + public KubernetesOperationsServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + sut = new KubernetesOperationsService(dbFactory); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ── Helpers ── + + private KubernetesCluster CreateCluster(string? kubeconfig = null) + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "production" }; + db.Environments.Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = kubeconfig + }; + db.KubernetesClusters.Add(cluster); + + db.SaveChanges(); + return cluster; + } + + private (AppDeployment deployment, KubernetesCluster cluster) CreateDeploymentWithCluster() + { + KubernetesCluster cluster = CreateCluster(); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = cluster.TenantId, Name = "Contoso" }; + db.Customers.Add(customer); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" }; + db.Apps.Add(app); + + AppDeployment deployment = new() + { + Id = Guid.NewGuid(), + AppId = app.Id, + Name = "billing-deploy", + Type = DeploymentType.Manual, + EnvironmentId = cluster.EnvironmentId, + ClusterId = cluster.Id, + Namespace = "billing-ns" + }; + db.AppDeployments.Add(deployment); + + db.SaveChanges(); + return (deployment, cluster); + } + + // ════════════════════════════════════════════════════════════════ + // GetDeploymentWithClusterAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetDeploymentWithClusterAsync_ReturnsDeploymentAndCluster() + { + // Arrange + (AppDeployment deployment, KubernetesCluster cluster) = CreateDeploymentWithCluster(); + + // Act — the service looks up the deployment with its cluster attached. + AppDeployment? result = await sut.GetDeploymentWithClusterAsync(deployment.Id); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("billing-deploy"); + result.Cluster.Should().NotBeNull(); + result.Cluster.Name.Should().Be("prod-cluster"); + } + + [Fact] + public async Task GetDeploymentWithClusterAsync_ReturnsNullForMissing() + { + // Act + AppDeployment? result = await sut.GetDeploymentWithClusterAsync(Guid.NewGuid()); + + // Assert + result.Should().BeNull(); + } + + // ════════════════════════════════════════════════════════════════ + // GetPodsAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetPodsAsync_WithoutKubeconfig_ReturnsClusterNotConfiguredError() + { + // Arrange — cluster has no kubeconfig stored. + (AppDeployment deployment, _) = CreateDeploymentWithCluster(); + + // Act — trying to get pods without a kubeconfig should return an error. + KubernetesOperationResult> result = await sut.GetPodsAsync(deployment.Id); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("kubeconfig"); + } + + // ════════════════════════════════════════════════════════════════ + // RestartDeploymentAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task RestartDeploymentAsync_WithoutKubeconfig_ReturnsError() + { + // Arrange + (AppDeployment deployment, _) = CreateDeploymentWithCluster(); + + // Act + KubernetesOperationResult result = await sut.RestartDeploymentAsync( + deployment.Id, "billing-api"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("kubeconfig"); + } + + // ════════════════════════════════════════════════════════════════ + // DeletePodAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task DeletePodAsync_WithoutKubeconfig_ReturnsError() + { + // Arrange + (AppDeployment deployment, _) = CreateDeploymentWithCluster(); + + // Act + KubernetesOperationResult result = await sut.DeletePodAsync( + deployment.Id, "billing-api-abc123"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("kubeconfig"); + } + + // ════════════════════════════════════════════════════════════════ + // GetPodLogsAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task GetPodLogsAsync_WithoutKubeconfig_ReturnsError() + { + // Arrange + (AppDeployment deployment, _) = CreateDeploymentWithCluster(); + + // Act + KubernetesOperationResult result = await sut.GetPodLogsAsync( + deployment.Id, "billing-api-abc123"); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("kubeconfig"); + } + + // ════════════════════════════════════════════════════════════════ + // ScaleDeploymentAsync + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ScaleDeploymentAsync_WithoutKubeconfig_ReturnsError() + { + // Arrange + (AppDeployment deployment, _) = CreateDeploymentWithCluster(); + + // Act + KubernetesOperationResult result = await sut.ScaleDeploymentAsync( + deployment.Id, "billing-api", 3); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("kubeconfig"); + } +} diff --git a/tests/EntKube.Web.Tests/PrometheusServiceTests.cs b/tests/EntKube.Web.Tests/PrometheusServiceTests.cs new file mode 100644 index 0000000..2a3f86b --- /dev/null +++ b/tests/EntKube.Web.Tests/PrometheusServiceTests.cs @@ -0,0 +1,551 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for PrometheusService — the service that connects to a kube-prometheus-stack +/// running in a cluster and retrieves health/status metrics. These tests verify the +/// data-layer plumbing (finding clusters, validating configuration) and response +/// parsing. Actual Prometheus queries require a live cluster. +/// +public class PrometheusServiceTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly PrometheusService sut; + + public PrometheusServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + sut = new PrometheusService(dbFactory); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + // ──────── GetClusterHealthAsync — failure paths ──────── + + [Fact] + public async Task GetClusterHealthAsync_ClusterNotFound_ReturnsFailure() + { + // Arrange — use a random cluster ID that doesn't exist in the database. + + Guid nonExistentId = Guid.NewGuid(); + + // Act + + KubernetesOperationResult result = + await sut.GetClusterHealthAsync(nonExistentId); + + // Assert — should fail gracefully with a clear message. + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("not found"); + } + + [Fact] + public async Task GetClusterHealthAsync_NoPrometheusComponent_ReturnsFailure() + { + // Arrange — cluster exists but has no prometheus component configured. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant", Slug = "test" }; + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" }; + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = "apiVersion: v1\nkind: Config" + }; + + db.Set().Add(tenant); + db.Set().Add(env); + db.KubernetesClusters.Add(cluster); + await db.SaveChangesAsync(); + + // Act + + KubernetesOperationResult result = + await sut.GetClusterHealthAsync(cluster.Id); + + // Assert — no prometheus component means we can't query metrics. + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("prometheus"); + } + + [Fact] + public async Task GetClusterHealthAsync_NoKubeconfig_ReturnsFailure() + { + // Arrange — cluster has prometheus component but no kubeconfig stored. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant2", Slug = "test2" }; + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" }; + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "no-kubeconfig-cluster", + ApiServerUrl = "https://k8s.example.com", + Kubeconfig = null + }; + + ClusterComponent prometheusComponent = new() + { + Id = Guid.NewGuid(), + ClusterId = cluster.Id, + Name = "kube-prometheus-stack", + ComponentType = "HelmChart", + Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}""" + }; + + db.Set().Add(tenant); + db.Set().Add(env); + db.KubernetesClusters.Add(cluster); + db.ClusterComponents.Add(prometheusComponent); + await db.SaveChangesAsync(); + + // Act + + KubernetesOperationResult result = + await sut.GetClusterHealthAsync(cluster.Id); + + // Assert — without kubeconfig we can't connect to the cluster. + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().Contain("kubeconfig"); + } + + // ──────── ParsePrometheusResponse — parsing metric results ──────── + + [Fact] + public void ParseInstantQueryResult_ValidVectorResponse_ReturnsValues() + { + // Arrange — a typical Prometheus instant query response for `up` metric. + + string json = """ + { + "status": "success", + "data": { + "resultType": "vector", + "result": [ + {"metric": {"job": "node-exporter", "instance": "node1:9100"}, "value": [1700000000, "1"]}, + {"metric": {"job": "node-exporter", "instance": "node2:9100"}, "value": [1700000000, "1"]}, + {"metric": {"job": "node-exporter", "instance": "node3:9100"}, "value": [1700000000, "0"]} + ] + } + } + """; + + // Act + + List results = PrometheusService.ParseInstantQueryResult(json); + + // Assert — three results parsed, with correct values. + + results.Should().HaveCount(3); + results[0].Value.Should().Be(1.0); + results[1].Value.Should().Be(1.0); + results[2].Value.Should().Be(0.0); + results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100"); + } + + [Fact] + public void ParseInstantQueryResult_EmptyResult_ReturnsEmptyList() + { + // Arrange — Prometheus returns success but no matching series. + + string json = """ + { + "status": "success", + "data": { + "resultType": "vector", + "result": [] + } + } + """; + + // Act + + List results = PrometheusService.ParseInstantQueryResult(json); + + // Assert + + results.Should().BeEmpty(); + } + + [Fact] + public void ParseInstantQueryResult_ErrorResponse_ReturnsEmptyList() + { + // Arrange — Prometheus returns an error status. + + string json = """ + { + "status": "error", + "errorType": "bad_data", + "error": "invalid expression" + } + """; + + // Act + + List results = PrometheusService.ParseInstantQueryResult(json); + + // Assert — graceful degradation, no exception thrown. + + results.Should().BeEmpty(); + } + + [Fact] + public void ParseInstantQueryResult_ScalarResponse_ReturnsSingleValue() + { + // Arrange — a scalar query result (e.g. `count(up)`). + + string json = """ + { + "status": "success", + "data": { + "resultType": "scalar", + "result": [1700000000, "42"] + } + } + """; + + // Act + + List results = PrometheusService.ParseInstantQueryResult(json); + + // Assert + + results.Should().HaveCount(1); + results[0].Value.Should().Be(42.0); + } + + // ──────── GetPrometheusConfig — extracting config from component ──────── + + [Fact] + public void GetPrometheusConfig_ValidJson_ReturnsConfig() + { + // Arrange — a component with properly formatted configuration JSON. + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = Guid.NewGuid(), + Name = "kube-prometheus-stack", + ComponentType = "HelmChart", + Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}""" + }; + + // Act + + PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component); + + // Assert + + config.Should().NotBeNull(); + config!.Namespace.Should().Be("monitoring"); + config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus"); + config.ServicePort.Should().Be(9090); + } + + [Fact] + public void GetPrometheusConfig_NullConfiguration_ReturnsDefaultConfig() + { + // Arrange — component exists but has no configuration set yet. + // We should return sensible defaults for kube-prometheus-stack. + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = Guid.NewGuid(), + Name = "kube-prometheus-stack", + ComponentType = "HelmChart", + Configuration = null + }; + + // Act + + PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component); + + // Assert — defaults for standard kube-prometheus-stack helm install. + + config.Should().NotBeNull(); + config!.Namespace.Should().Be("monitoring"); + config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus"); + config.ServicePort.Should().Be(9090); + } + + [Fact] + public void GetPrometheusConfig_InvalidJson_ReturnsDefaultConfig() + { + // Arrange — configuration is corrupt JSON. + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = Guid.NewGuid(), + Name = "kube-prometheus-stack", + ComponentType = "HelmChart", + Configuration = "not valid json {" + }; + + // Act + + PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component); + + // Assert — graceful fallback to defaults. + + config.Should().NotBeNull(); + config!.Namespace.Should().Be("monitoring"); + } + + // ──────── ParseRangeQueryResult — time-series parsing ──────── + + [Fact] + public void ParseRangeQueryResult_ValidMatrixResponse_ReturnsTimeSeries() + { + // Arrange — a typical Prometheus range query response with matrix data. + // Each result is a series with multiple [timestamp, value] pairs over time. + + string json = """ + { + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": {"instance": "node1:9100"}, + "values": [ + [1700000000, "45.2"], + [1700000060, "47.1"], + [1700000120, "43.8"], + [1700000180, "50.3"] + ] + } + ] + } + } + """; + + // Act + + List results = PrometheusService.ParseRangeQueryResult(json); + + // Assert — one series with four data points. + + results.Should().HaveCount(1); + results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100"); + results[0].DataPoints.Should().HaveCount(4); + results[0].DataPoints[0].Value.Should().BeApproximately(45.2, 0.01); + results[0].DataPoints[3].Value.Should().BeApproximately(50.3, 0.01); + results[0].DataPoints[0].Timestamp.Should().BeAfter(DateTime.UnixEpoch); + } + + [Fact] + public void ParseRangeQueryResult_MultipleSeriesResponse_ReturnsAll() + { + // Arrange — two series (e.g. CPU per node) in a range query. + + string json = """ + { + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": {"node": "node1"}, + "values": [[1700000000, "30.0"], [1700000060, "35.0"]] + }, + { + "metric": {"node": "node2"}, + "values": [[1700000000, "60.0"], [1700000060, "62.5"]] + } + ] + } + } + """; + + // Act + + List results = PrometheusService.ParseRangeQueryResult(json); + + // Assert + + results.Should().HaveCount(2); + results[0].Labels["node"].Should().Be("node1"); + results[1].Labels["node"].Should().Be("node2"); + results[1].DataPoints[1].Value.Should().BeApproximately(62.5, 0.01); + } + + [Fact] + public void ParseRangeQueryResult_EmptyResult_ReturnsEmptyList() + { + // Arrange + + string json = """ + { + "status": "success", + "data": { + "resultType": "matrix", + "result": [] + } + } + """; + + // Act + + List results = PrometheusService.ParseRangeQueryResult(json); + + // Assert + + results.Should().BeEmpty(); + } + + // ──────── ParseAlertmanagerAlerts — alertmanager response parsing ──────── + + [Fact] + public void ParseAlertmanagerAlerts_ValidResponse_ReturnsAlerts() + { + // Arrange — Alertmanager /api/v2/alerts returns an array of alert objects. + + string json = """ + [ + { + "labels": {"alertname": "HighCPU", "severity": "warning", "instance": "node1:9100"}, + "annotations": {"summary": "CPU usage is above 90%", "description": "Node node1 has high CPU load"}, + "startsAt": "2026-05-16T10:30:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "status": {"state": "active"}, + "fingerprint": "abc123" + }, + { + "labels": {"alertname": "PodCrashLooping", "severity": "critical", "namespace": "default", "pod": "api-7f8b9-xyz"}, + "annotations": {"summary": "Pod is crash looping"}, + "startsAt": "2026-05-16T09:15:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "status": {"state": "active"}, + "fingerprint": "def456" + } + ] + """; + + // Act + + List alerts = PrometheusService.ParseAlertmanagerAlerts(json); + + // Assert — two alerts parsed with correct severity and labels. + + alerts.Should().HaveCount(2); + alerts[0].Name.Should().Be("HighCPU"); + alerts[0].Severity.Should().Be("warning"); + alerts[0].Summary.Should().Contain("CPU usage"); + alerts[0].State.Should().Be("active"); + alerts[0].Labels.Should().ContainKey("instance"); + alerts[1].Name.Should().Be("PodCrashLooping"); + alerts[1].Severity.Should().Be("critical"); + } + + [Fact] + public void ParseAlertmanagerAlerts_EmptyArray_ReturnsEmptyList() + { + // Arrange + + string json = "[]"; + + // Act + + List alerts = PrometheusService.ParseAlertmanagerAlerts(json); + + // Assert + + alerts.Should().BeEmpty(); + } + + [Fact] + public void ParseAlertmanagerAlerts_InvalidJson_ReturnsEmptyList() + { + // Arrange — corrupt JSON should degrade gracefully. + + string json = "not valid json {"; + + // Act + + List alerts = PrometheusService.ParseAlertmanagerAlerts(json); + + // Assert + + alerts.Should().BeEmpty(); + } + + // ──────── ParseAlertmanagerSilences — silence parsing ──────── + + [Fact] + public void ParseAlertmanagerSilences_ValidResponse_ReturnsSilences() + { + // Arrange — Alertmanager /api/v2/silences response. + + string json = """ + [ + { + "id": "silence-001", + "status": {"state": "active"}, + "comment": "Maintenance window for node upgrade", + "createdBy": "ops-team", + "startsAt": "2026-05-16T08:00:00Z", + "endsAt": "2026-05-16T12:00:00Z", + "matchers": [ + {"name": "instance", "value": "node1:9100", "isRegex": false, "isEqual": true} + ] + }, + { + "id": "silence-002", + "status": {"state": "expired"}, + "comment": "Past maintenance", + "createdBy": "admin", + "startsAt": "2026-05-15T00:00:00Z", + "endsAt": "2026-05-15T04:00:00Z", + "matchers": [ + {"name": "alertname", "value": "Watchdog", "isRegex": false, "isEqual": true} + ] + } + ] + """; + + // Act + + List silences = PrometheusService.ParseAlertmanagerSilences(json); + + // Assert — two silences, one active and one expired. + + silences.Should().HaveCount(2); + silences[0].Id.Should().Be("silence-001"); + silences[0].State.Should().Be("active"); + silences[0].Comment.Should().Contain("Maintenance"); + silences[0].CreatedBy.Should().Be("ops-team"); + silences[0].Matchers.Should().HaveCount(1); + silences[0].Matchers[0].Name.Should().Be("instance"); + silences[1].State.Should().Be("expired"); + } +} diff --git a/tests/EntKube.Web.Tests/SlugGenerationTests.cs b/tests/EntKube.Web.Tests/SlugGenerationTests.cs new file mode 100644 index 0000000..19652d3 --- /dev/null +++ b/tests/EntKube.Web.Tests/SlugGenerationTests.cs @@ -0,0 +1,29 @@ +using EntKube.Web.Services; +using FluentAssertions; + +namespace EntKube.Web.Tests; + +/// +/// The slug is auto-generated from the tenant name — users shouldn't have to +/// think about URL encoding. These tests verify the conversion logic. +/// +public class SlugGenerationTests +{ + [Theory] + [InlineData("Acme Corp", "acme-corp")] + [InlineData("My Cool Tenant", "my-cool-tenant")] + [InlineData("hello", "hello")] + [InlineData("UPPERCASE", "uppercase")] + [InlineData("special!@#chars", "special-chars")] + [InlineData(" spaces ", "spaces")] + [InlineData("multi---hyphens", "multi-hyphens")] + [InlineData("trailing-", "trailing")] + [InlineData("-leading", "leading")] + [InlineData("dots.and.periods", "dots-and-periods")] + public void GenerateSlug_ProducesExpectedResult(string name, string expected) + { + string slug = TenantService.GenerateSlug(name); + + slug.Should().Be(expected); + } +} diff --git a/tests/EntKube.Web.Tests/StorageServiceTests.cs b/tests/EntKube.Web.Tests/StorageServiceTests.cs new file mode 100644 index 0000000..9e61812 --- /dev/null +++ b/tests/EntKube.Web.Tests/StorageServiceTests.cs @@ -0,0 +1,797 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the StorageService which manages external storage links (AWS S3, +/// Azure Storage, Cleura S3) and MinIO discovery. Uses SQLite in-memory for +/// fast, isolated database tests. +/// +/// Note: MinIO discovery tests are not included here because they require +/// a live Kubernetes cluster. These tests focus on the CRUD operations +/// for external storage links and vault credential management. +/// +public class StorageServiceTests : IDisposable +{ + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly VaultService vaultService; + private readonly StorageService sut; + + public StorageServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + VaultEncryptionService encryption = new(TestRootKey); + vaultService = new VaultService(dbFactory, encryption); + Mock httpFactory = new(); + OpenStackS3Service openStackS3 = new(vaultService, httpFactory.Object); + sut = new StorageService(dbFactory, vaultService, openStackS3); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + private (Tenant tenant, Data.Environment env) CreateTenantWithEnvironment() + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestCo", Slug = "testco" }; + db.Tenants.Add(tenant); + + Data.Environment env = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Production" + }; + db.Set().Add(env); + db.SaveChanges(); + + return (tenant, env); + } + + // ──────── IsMinioAvailableAsync ──────── + + [Fact] + public async Task IsMinioAvailableAsync_NoComponents_ReturnsFalse() + { + // Arrange — tenant with no clusters/components. + + (Tenant tenant, _) = CreateTenantWithEnvironment(); + + // Act + + bool result = await sut.IsMinioAvailableAsync(tenant.Id); + + // Assert + + result.Should().BeFalse(); + } + + [Fact] + public async Task IsMinioAvailableAsync_MinioInstalled_ReturnsTrue() + { + // Arrange — tenant with a cluster that has minio installed. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com" + }; + db.KubernetesClusters.Add(cluster); + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = cluster.Id, + Name = "minio", + ComponentType = "helm", + Status = ComponentStatus.Installed + }; + db.ClusterComponents.Add(component); + await db.SaveChangesAsync(); + + // Act + + bool result = await sut.IsMinioAvailableAsync(tenant.Id); + + // Assert + + result.Should().BeTrue(); + } + + // ──────── GetStorageLinksAsync ──────── + + [Fact] + public async Task GetStorageLinksAsync_NoLinks_ReturnsEmpty() + { + // Arrange + + (Tenant tenant, _) = CreateTenantWithEnvironment(); + + // Act + + List result = await sut.GetStorageLinksAsync(tenant.Id); + + // Assert + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetStorageLinksAsync_WithLinks_ReturnsAll() + { + // Arrange — add two links for the tenant. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + db.StorageLinks.AddRange( + new StorageLink + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.AwsS3, + Name = "Backups" + }, + new StorageLink + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Media" + }); + await db.SaveChangesAsync(); + + // Act + + List result = await sut.GetStorageLinksAsync(tenant.Id); + + // Assert + + result.Should().HaveCount(2); + result.Select(s => s.Name).Should().Contain("Backups").And.Contain("Media"); + } + + [Fact] + public async Task GetStorageLinksAsync_FilterByEnvironment_ReturnsFiltered() + { + // Arrange — two environments, one link each. + + (Tenant tenant, Data.Environment env1) = CreateTenantWithEnvironment(); + + Data.Environment env2 = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Staging" + }; + db.Set().Add(env2); + + db.StorageLinks.AddRange( + new StorageLink + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env1.Id, + Provider = StorageProvider.AwsS3, + Name = "Prod Backups" + }, + new StorageLink + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env2.Id, + Provider = StorageProvider.AwsS3, + Name = "Staging Backups" + }); + await db.SaveChangesAsync(); + + // Act + + List result = await sut.GetStorageLinksAsync(tenant.Id, env1.Id); + + // Assert + + result.Should().HaveCount(1); + result[0].Name.Should().Be("Prod Backups"); + } + + // ──────── CreateStorageLinkAsync ──────── + + [Fact] + public async Task CreateStorageLinkAsync_CreatesLinkAndStoresCredentials() + { + // Arrange + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + // Act — create a link with credentials. + + StorageLink link = await sut.CreateStorageLinkAsync( + tenant.Id, + env.Id, + StorageProvider.AwsS3, + "My Bucket", + "https://s3.eu-west-1.amazonaws.com", + "my-app-backups", + "eu-west-1", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Production backup bucket"); + + // Assert — the link is persisted. + + StorageLink? saved = await db.StorageLinks.FindAsync(link.Id); + saved.Should().NotBeNull(); + saved!.Name.Should().Be("My Bucket"); + saved.Provider.Should().Be(StorageProvider.AwsS3); + saved.BucketName.Should().Be("my-app-backups"); + saved.Region.Should().Be("eu-west-1"); + saved.Endpoint.Should().Be("https://s3.eu-west-1.amazonaws.com"); + saved.Notes.Should().Be("Production backup bucket"); + + // Assert — credentials are stored in the vault. + + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); + secrets.Should().HaveCount(2); + secrets.Select(s => s.Name).Should().Contain("ACCESS_KEY").And.Contain("SECRET_KEY"); + } + + [Fact] + public async Task CreateStorageLinkAsync_WithoutCredentials_CreatesLinkOnly() + { + // Arrange + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + // Act — create a link without credentials (just a reference). + + StorageLink link = await sut.CreateStorageLinkAsync( + tenant.Id, + env.Id, + StorageProvider.AzureStorage, + "Azure Media", + "https://myaccount.blob.core.windows.net", + "media-container", + "swedencentral", + null, + null, + null); + + // Assert — link exists, no vault secrets. + + StorageLink? saved = await db.StorageLinks.FindAsync(link.Id); + saved.Should().NotBeNull(); + saved!.Provider.Should().Be(StorageProvider.AzureStorage); + + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); + secrets.Should().BeEmpty(); + } + + // ──────── UpdateStorageLinkAsync ──────── + + [Fact] + public async Task UpdateStorageLinkAsync_UpdatesMetadata() + { + // Arrange + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Old Name", + BucketName = "old-bucket" + }; + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + // Act + + await sut.UpdateStorageLinkAsync( + link.Id, "New Name", "https://new-endpoint.com", "new-bucket", "eu-north-1", "Updated notes"); + + // Assert — clear tracking so we get fresh data from DB + + db.ChangeTracker.Clear(); + StorageLink? updated = await db.StorageLinks.FindAsync(link.Id); + updated!.Name.Should().Be("New Name"); + updated.Endpoint.Should().Be("https://new-endpoint.com"); + updated.BucketName.Should().Be("new-bucket"); + updated.Region.Should().Be("eu-north-1"); + updated.Notes.Should().Be("Updated notes"); + } + + // ──────── DeleteStorageLinkAsync ──────── + + [Fact] + public async Task DeleteStorageLinkAsync_RemovesLinkAndVaultSecrets() + { + // Arrange — create a link with credentials. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + StorageLink link = await sut.CreateStorageLinkAsync( + tenant.Id, env.Id, StorageProvider.AwsS3, "To Delete", + "https://s3.amazonaws.com", "bucket", "us-east-1", + "key123", "secret456", null); + + // Verify credentials exist before deletion. + + List secretsBefore = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); + secretsBefore.Should().HaveCount(2); + + // Act + + await sut.DeleteStorageLinkAsync(tenant.Id, link.Id); + + // Assert — link and secrets are gone. + + StorageLink? deleted = await db.StorageLinks.FindAsync(link.Id); + deleted.Should().BeNull(); + + List secretsAfter = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); + secretsAfter.Should().BeEmpty(); + } + + // ──────── GetEnvironmentsAsync ──────── + + [Fact] + public async Task GetEnvironmentsAsync_ReturnsTenantEnvironments() + { + // Arrange + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + // Act + + List result = await sut.GetEnvironmentsAsync(tenant.Id); + + // Assert + + result.Should().HaveCount(1); + result[0].Name.Should().Be("Production"); + } + + // ──────── OpenStack Connections ──────── + + [Fact] + public async Task GetOpenStackConnectionsAsync_NoConnections_ReturnsEmpty() + { + // Arrange + + (Tenant tenant, _) = CreateTenantWithEnvironment(); + + // Act + + List result = await sut.GetOpenStackConnectionsAsync(tenant.Id); + + // Assert + + result.Should().BeEmpty(); + } + + [Fact] + public async Task CreateOpenStackConnectionAsync_CreatesConnectionAndStoresPassword() + { + // Arrange + + (Tenant tenant, _) = CreateTenantWithEnvironment(); + + // Act + + OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( + tenant.Id, + "Cleura Prod", + "https://identity.c2.citycloud.com:5000/v3", + "Kna1", + "my-project", + "abc123", + "Default", + "Default", + "admin@example.com", + "supersecret"); + + // Assert — connection is persisted with metadata. + + OpenStackConnection? saved = await db.OpenStackConnections.FindAsync(connection.Id); + saved.Should().NotBeNull(); + saved!.Name.Should().Be("Cleura Prod"); + saved.AuthUrl.Should().Be("https://identity.c2.citycloud.com:5000/v3"); + saved.Region.Should().Be("Kna1"); + saved.ProjectName.Should().Be("my-project"); + saved.ProjectId.Should().Be("abc123"); + saved.UserDomainName.Should().Be("Default"); + saved.ProjectDomainName.Should().Be("Default"); + saved.Username.Should().Be("admin@example.com"); + + // Assert — password is in the vault. + + List secrets = await vaultService.GetOpenStackSecretsAsync(tenant.Id, connection.Id); + secrets.Should().HaveCount(1); + secrets[0].Name.Should().Be("OS_PASSWORD"); + } + + [Fact] + public async Task DeleteOpenStackConnectionAsync_RemovesConnectionAndVaultSecrets() + { + // Arrange + + (Tenant tenant, _) = CreateTenantWithEnvironment(); + + OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( + tenant.Id, "ToDelete", "https://auth.example.com/v3", + "Sto2", null, null, null, null, "user", "pass123"); + + // Act + + bool deleted = await sut.DeleteOpenStackConnectionAsync(tenant.Id, connection.Id); + + // Assert + + deleted.Should().BeTrue(); + + OpenStackConnection? found = await db.OpenStackConnections.FindAsync(connection.Id); + found.Should().BeNull(); + + List secrets = await vaultService.GetOpenStackSecretsAsync(tenant.Id, connection.Id); + secrets.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteOpenStackConnectionAsync_WithLinkedStorage_ReturnsFalse() + { + // Arrange — create a connection with a storage link referencing it. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( + tenant.Id, "InUse", "https://auth.example.com/v3", + "Kna1", null, null, null, null, null, null); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Cleura Bucket", + OpenStackConnectionId = connection.Id + }; + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + // Act — should not delete because a link depends on it. + + bool deleted = await sut.DeleteOpenStackConnectionAsync(tenant.Id, connection.Id); + + // Assert + + deleted.Should().BeFalse(); + + OpenStackConnection? stillExists = await db.OpenStackConnections.FindAsync(connection.Id); + stillExists.Should().NotBeNull(); + } + + [Fact] + public async Task CreateStorageLinkAsync_CleuraWithOpenStack_SetsConnectionId() + { + // Arrange + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + OpenStackConnection connection = await sut.CreateOpenStackConnectionAsync( + tenant.Id, "Cleura", "https://auth.example.com/v3", + "Kna1", null, null, null, null, null, null); + + // Act + + StorageLink link = await sut.CreateStorageLinkAsync( + tenant.Id, env.Id, StorageProvider.CleuraS3, "Cleura Bucket", + "https://s3-kna1.cloudferro.com", "my-bucket", "Kna1", + null, null, null, connection.Id); + + // Assert + + StorageLink? saved = await db.StorageLinks.FindAsync(link.Id); + saved.Should().NotBeNull(); + saved!.OpenStackConnectionId.Should().Be(connection.Id); + saved.Provider.Should().Be(StorageProvider.CleuraS3); + } + + // ══════════════════════════════════════════════════════════════ + // Storage Bindings + // ══════════════════════════════════════════════════════════════ + + private (KubernetesCluster cluster, AppDeployment deployment, StorageLink link) CreateDeploymentWithStorageLink( + Tenant tenant, Data.Environment env) + { + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Acme" }; + db.Customers.Add(customer); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "billing-api" }; + db.Apps.Add(app); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com" + }; + db.KubernetesClusters.Add(cluster); + + AppDeployment deployment = new() + { + Id = Guid.NewGuid(), + AppId = app.Id, + Name = "billing-api-prod", + Type = DeploymentType.HelmChart, + EnvironmentId = env.Id, + ClusterId = cluster.Id, + Namespace = "billing" + }; + db.AppDeployments.Add(deployment); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.AwsS3, + Name = "Invoice PDFs", + Endpoint = "https://s3.eu-west-1.amazonaws.com", + BucketName = "acme-invoices", + Region = "eu-west-1" + }; + db.StorageLinks.Add(link); + db.SaveChanges(); + + return (cluster, deployment, link); + } + + [Fact] + public async Task BindStorageToDeploymentAsync_CreatesBinding() + { + // Arrange — a deployment and a storage link ready to be connected. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); + + // Act — bind the storage to the deployment so credentials will be synced + // to a K8s Secret named "invoice-storage" in the deployment's namespace. + + StorageBinding binding = await sut.BindStorageToDeploymentAsync( + link.Id, deployment.Id, "invoice-storage"); + + // Assert — the binding exists and references both sides correctly. + + binding.Should().NotBeNull(); + binding.StorageLinkId.Should().Be(link.Id); + binding.AppDeploymentId.Should().Be(deployment.Id); + binding.ComponentId.Should().BeNull(); + binding.KubernetesSecretName.Should().Be("invoice-storage"); + binding.SyncEnabled.Should().BeTrue(); + + StorageBinding? persisted = await db.Set().FindAsync(binding.Id); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task BindStorageToComponentAsync_CreatesBinding() + { + // Arrange — a cluster component that needs access to storage. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "infra-cluster", + ApiServerUrl = "https://k8s.infra.example.com" + }; + db.KubernetesClusters.Add(cluster); + + ClusterComponent component = new() + { + Id = Guid.NewGuid(), + ClusterId = cluster.Id, + Name = "loki", + ComponentType = "helm", + Namespace = "monitoring" + }; + db.ClusterComponents.Add(component); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.CleuraS3, + Name = "Loki Chunks", + Endpoint = "https://s3-kna1.citycloud.com", + BucketName = "loki-chunks", + Region = "Kna1" + }; + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + // Act — bind storage to the component. + + StorageBinding binding = await sut.BindStorageToComponentAsync( + link.Id, component.Id, "loki-s3-credentials"); + + // Assert + + binding.Should().NotBeNull(); + binding.StorageLinkId.Should().Be(link.Id); + binding.AppDeploymentId.Should().BeNull(); + binding.ComponentId.Should().Be(component.Id); + binding.KubernetesSecretName.Should().Be("loki-s3-credentials"); + } + + [Fact] + public async Task GetBindingsForDeploymentAsync_ReturnsOnlyDeploymentBindings() + { + // Arrange — one deployment with two storage bindings. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); + + StorageLink link2 = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.AzureStorage, + Name = "Logs", + Endpoint = "https://acmelogs.blob.core.windows.net", + BucketName = "logs" + }; + db.StorageLinks.Add(link2); + await db.SaveChangesAsync(); + + await sut.BindStorageToDeploymentAsync(link.Id, deployment.Id, "invoices"); + await sut.BindStorageToDeploymentAsync(link2.Id, deployment.Id, "logs"); + + // Act + + List bindings = await sut.GetBindingsForDeploymentAsync(deployment.Id); + + // Assert + + bindings.Should().HaveCount(2); + bindings.Select(b => b.KubernetesSecretName) + .Should().Contain("invoices").And.Contain("logs"); + bindings.Should().AllSatisfy(b => b.StorageLink.Should().NotBeNull()); + } + + [Fact] + public async Task UnbindStorageAsync_RemovesBinding() + { + // Arrange + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); + + StorageBinding binding = await sut.BindStorageToDeploymentAsync( + link.Id, deployment.Id, "temp-storage"); + + // Act — unbind the storage. + + bool removed = await sut.UnbindStorageAsync(binding.Id); + + // Assert + + removed.Should().BeTrue(); + StorageBinding? found = await db.Set().FindAsync(binding.Id); + found.Should().BeNull(); + } + + [Fact] + public async Task UnbindStorageAsync_NonExistentBinding_ReturnsFalse() + { + // Act + + bool removed = await sut.UnbindStorageAsync(Guid.NewGuid()); + + // Assert + + removed.Should().BeFalse(); + } + + [Fact] + public async Task BindStorageToDeploymentAsync_ConfiguresVaultSecretsForSync() + { + // Arrange — a storage link with credentials already in the vault. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); + + // Store S3 credentials in the vault for this storage link. + + await vaultService.InitializeVaultAsync(tenant.Id); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKIAEXAMPLE"); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "s3cr3t"); + + // Act — bind the storage, which should mark vault secrets for K8s sync. + + StorageBinding binding = await sut.BindStorageToDeploymentAsync( + link.Id, deployment.Id, "invoice-storage"); + + // Assert — the storage link's secrets should now have K8s sync configured + // targeting the deployment's namespace and the binding's secret name. + + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); + secrets.Should().HaveCount(2); + secrets.Should().AllSatisfy(s => + { + s.SyncToKubernetes.Should().BeTrue(); + s.KubernetesSecretName.Should().Be("invoice-storage"); + s.KubernetesNamespace.Should().Be("billing"); + }); + } + + [Fact] + public async Task UnbindStorageAsync_DisablesVaultSecretSync() + { + // Arrange — bind a storage with credentials, then unbind. + + (Tenant tenant, Data.Environment env) = CreateTenantWithEnvironment(); + (_, AppDeployment deployment, StorageLink link) = CreateDeploymentWithStorageLink(tenant, env); + + await vaultService.InitializeVaultAsync(tenant.Id); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKIAEXAMPLE"); + await vaultService.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "s3cr3t"); + + StorageBinding binding = await sut.BindStorageToDeploymentAsync( + link.Id, deployment.Id, "invoice-storage"); + + // Act — unbind. + + await sut.UnbindStorageAsync(binding.Id); + + // Assert — secrets should no longer be marked for K8s sync. + + List secrets = await vaultService.GetStorageLinkSecretsAsync(tenant.Id, link.Id); + secrets.Should().AllSatisfy(s => + { + s.SyncToKubernetes.Should().BeFalse(); + s.KubernetesSecretName.Should().BeNull(); + s.KubernetesNamespace.Should().BeNull(); + }); + } +} diff --git a/tests/EntKube.Web.Tests/TenantEntityTests.cs b/tests/EntKube.Web.Tests/TenantEntityTests.cs new file mode 100644 index 0000000..bb893b9 --- /dev/null +++ b/tests/EntKube.Web.Tests/TenantEntityTests.cs @@ -0,0 +1,191 @@ +using EntKube.Web.Data; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// A tenant represents an organization or workspace in EntKube. Users can +/// belong to multiple tenants, each membership carrying a role that determines +/// what that user can do within that tenant's scope. +/// +public class TenantEntityTests : IDisposable +{ + private readonly SqliteConnection connection; + private readonly ApplicationDbContext context; + + public TenantEntityTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + context = new ApplicationDbContext(options); + context.Database.EnsureCreated(); + } + + [Fact] + public async Task Tenant_CanBeCreated_WithNameAndSlug() + { + // A tenant is the fundamental organizational unit. Every resource + // in EntKube is scoped to a tenant. We need at minimum a name + // (human-friendly) and a slug (URL-safe identifier). + + Tenant tenant = new() + { + Id = Guid.NewGuid(), + Name = "Acme Corp", + Slug = "acme-corp" + }; + + context.Tenants.Add(tenant); + await context.SaveChangesAsync(); + + Tenant? retrieved = await context.Tenants.FirstOrDefaultAsync(t => t.Slug == "acme-corp"); + + retrieved.Should().NotBeNull(); + retrieved!.Name.Should().Be("Acme Corp"); + } + + [Fact] + public async Task Tenant_SlugMustBeUnique() + { + // Two tenants cannot share the same slug — it's the unique identifier + // used in URLs and API calls to target a specific tenant. + + Tenant first = new() { Id = Guid.NewGuid(), Name = "First", Slug = "shared-slug" }; + Tenant second = new() { Id = Guid.NewGuid(), Name = "Second", Slug = "shared-slug" }; + + context.Tenants.Add(first); + await context.SaveChangesAsync(); + + context.Tenants.Add(second); + Func act = async () => await context.SaveChangesAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TenantRole_CanBeCreated_WithinTenant() + { + // Each tenant defines its own set of roles. The "Administrator" role + // is the primary role that grants full control within a tenant. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme Corp", Slug = "acme" }; + context.Tenants.Add(tenant); + + TenantRole adminRole = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + Name = "Administrator" + }; + + context.TenantRoles.Add(adminRole); + await context.SaveChangesAsync(); + + TenantRole? retrieved = await context.TenantRoles + .FirstOrDefaultAsync(r => r.TenantId == tenant.Id && r.Name == "Administrator"); + + retrieved.Should().NotBeNull(); + } + + [Fact] + public async Task TenantMembership_AssociatesUserWithTenantAndRole() + { + // A user joins a tenant through a membership, which also assigns them + // a role within that tenant. This is the many-to-many relationship + // with role context — a user might be an Administrator in one tenant + // but a regular member in another. + + Tenant tenant = new() { Id = Guid.NewGuid(), Name = "Acme", Slug = "acme" }; + context.Tenants.Add(tenant); + + TenantRole role = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Administrator" }; + context.TenantRoles.Add(role); + + ApplicationUser user = new() + { + Id = Guid.NewGuid().ToString(), + UserName = "admin@acme.com", + NormalizedUserName = "ADMIN@ACME.COM", + Email = "admin@acme.com", + NormalizedEmail = "ADMIN@ACME.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString() + }; + + context.Users.Add(user); + + TenantMembership membership = new() + { + UserId = user.Id, + TenantId = tenant.Id, + RoleId = role.Id + }; + + context.TenantMemberships.Add(membership); + await context.SaveChangesAsync(); + + // Verify we can navigate from a user to their tenants and role. + TenantMembership? retrieved = await context.TenantMemberships + .Include(m => m.Tenant) + .Include(m => m.Role) + .FirstOrDefaultAsync(m => m.UserId == user.Id); + + retrieved.Should().NotBeNull(); + retrieved!.Tenant.Name.Should().Be("Acme"); + retrieved.Role.Name.Should().Be("Administrator"); + } + + [Fact] + public async Task User_CanBelongToMultipleTenants() + { + // A user might work across several organizations. Each membership + // is independent — different tenants, potentially different roles. + + ApplicationUser user = new() + { + Id = Guid.NewGuid().ToString(), + UserName = "user@example.com", + NormalizedUserName = "USER@EXAMPLE.COM", + Email = "user@example.com", + NormalizedEmail = "USER@EXAMPLE.COM", + EmailConfirmed = true, + SecurityStamp = Guid.NewGuid().ToString() + }; + + context.Users.Add(user); + + Tenant tenantA = new() { Id = Guid.NewGuid(), Name = "Tenant A", Slug = "tenant-a" }; + Tenant tenantB = new() { Id = Guid.NewGuid(), Name = "Tenant B", Slug = "tenant-b" }; + context.Tenants.AddRange(tenantA, tenantB); + + TenantRole adminRole = new() { Id = Guid.NewGuid(), TenantId = tenantA.Id, Name = "Administrator" }; + TenantRole memberRole = new() { Id = Guid.NewGuid(), TenantId = tenantB.Id, Name = "Member" }; + context.TenantRoles.AddRange(adminRole, memberRole); + + context.TenantMemberships.AddRange( + new TenantMembership { UserId = user.Id, TenantId = tenantA.Id, RoleId = adminRole.Id }, + new TenantMembership { UserId = user.Id, TenantId = tenantB.Id, RoleId = memberRole.Id } + ); + + await context.SaveChangesAsync(); + + List memberships = await context.TenantMemberships + .Where(m => m.UserId == user.Id) + .ToListAsync(); + + memberships.Should().HaveCount(2); + } + + public void Dispose() + { + context.Dispose(); + connection.Dispose(); + } +} diff --git a/tests/EntKube.Web.Tests/TestDbContextFactory.cs b/tests/EntKube.Web.Tests/TestDbContextFactory.cs new file mode 100644 index 0000000..87aeb4e --- /dev/null +++ b/tests/EntKube.Web.Tests/TestDbContextFactory.cs @@ -0,0 +1,28 @@ +using EntKube.Web.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// A test-only IDbContextFactory that produces ApplicationDbContext instances +/// sharing the same in-memory SQLite connection. This ensures all contexts +/// created by the factory see the same seeded test data. +/// +public sealed class TestDbContextFactory : IDbContextFactory +{ + private readonly SqliteConnection connection; + + public TestDbContextFactory(SqliteConnection connection) + { + this.connection = connection; + } + + public ApplicationDbContext CreateDbContext() + { + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + return new ApplicationDbContext(options); + } +} diff --git a/tests/EntKube.Web.Tests/VaultEncryptionServiceTests.cs b/tests/EntKube.Web.Tests/VaultEncryptionServiceTests.cs new file mode 100644 index 0000000..1daf9cf --- /dev/null +++ b/tests/EntKube.Web.Tests/VaultEncryptionServiceTests.cs @@ -0,0 +1,177 @@ +using EntKube.Web.Services; +using FluentAssertions; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the envelope encryption service that powers the secrets vault. +/// The service uses AES-256-GCM at both layers: root key → DEK, DEK → secret values. +/// +public class VaultEncryptionServiceTests +{ + // A stable 32-byte root key for testing purposes. + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly VaultEncryptionService sut = new(TestRootKey); + + // --- Data Key Generation --- + + [Fact] + public void GenerateDataKey_ReturnsThirtyTwoBytes() + { + // A data encryption key should be 256 bits (32 bytes) for AES-256. + + byte[] dataKey = sut.GenerateDataKey(); + + dataKey.Should().HaveCount(32); + } + + [Fact] + public void GenerateDataKey_ProducesUniqueKeysEachCall() + { + // Every call should produce a cryptographically random key. + + byte[] key1 = sut.GenerateDataKey(); + byte[] key2 = sut.GenerateDataKey(); + + key1.Should().NotEqual(key2); + } + + // --- Sealing and Unsealing Data Keys --- + + [Fact] + public void SealDataKey_ProducesNonEmptyCiphertextAndNonce() + { + // Sealing a DEK with the root key should produce ciphertext + nonce. + + byte[] dataKey = sut.GenerateDataKey(); + + (byte[] encryptedKey, byte[] nonce) = sut.SealDataKey(dataKey); + + encryptedKey.Should().NotBeEmpty(); + nonce.Should().NotBeEmpty(); + encryptedKey.Should().NotEqual(dataKey); + } + + [Fact] + public void UnsealDataKey_RecoversOriginalKey() + { + // Unsealing a previously sealed DEK should return the exact same key. + + byte[] originalKey = sut.GenerateDataKey(); + + (byte[] encryptedKey, byte[] nonce) = sut.SealDataKey(originalKey); + byte[] recovered = sut.UnsealDataKey(encryptedKey, nonce); + + recovered.Should().Equal(originalKey); + } + + [Fact] + public void UnsealDataKey_WithWrongRootKey_Throws() + { + // If the root key is different, unsealing must fail. + + byte[] dataKey = sut.GenerateDataKey(); + (byte[] encryptedKey, byte[] nonce) = sut.SealDataKey(dataKey); + + // Create a service with a different root key. + byte[] wrongRoot = new byte[32]; + Array.Fill(wrongRoot, (byte)0xFF); + VaultEncryptionService wrongService = new(wrongRoot); + + Action act = () => wrongService.UnsealDataKey(encryptedKey, nonce); + + act.Should().Throw(); + } + + // --- Encrypting and Decrypting Secret Values --- + + [Fact] + public void Encrypt_ProducesCiphertextDifferentFromPlaintext() + { + // Encrypting a secret value should produce unreadable ciphertext. + + byte[] dataKey = sut.GenerateDataKey(); + string plaintext = "super-secret-database-password"; + + (byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, plaintext); + + ciphertext.Should().NotBeEmpty(); + nonce.Should().NotBeEmpty(); + System.Text.Encoding.UTF8.GetString(ciphertext).Should().NotBe(plaintext); + } + + [Fact] + public void Decrypt_RecoversOriginalPlaintext() + { + // Decrypting should return the exact original secret value. + + byte[] dataKey = sut.GenerateDataKey(); + string original = "my-api-key-12345!@#$%"; + + (byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, original); + string recovered = sut.Decrypt(dataKey, ciphertext, nonce); + + recovered.Should().Be(original); + } + + [Fact] + public void Decrypt_WithWrongDataKey_Throws() + { + // Using the wrong DEK to decrypt must fail — tenant isolation. + + byte[] dataKey1 = sut.GenerateDataKey(); + byte[] dataKey2 = sut.GenerateDataKey(); + string plaintext = "tenant-a-secret"; + + (byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey1, plaintext); + + Action act = () => sut.Decrypt(dataKey2, ciphertext, nonce); + + act.Should().Throw(); + } + + [Fact] + public void Encrypt_SamePlaintext_ProducesDifferentCiphertext() + { + // Each encryption uses a unique nonce so identical plaintexts + // produce different ciphertexts — preventing pattern analysis. + + byte[] dataKey = sut.GenerateDataKey(); + string plaintext = "repeated-secret"; + + (byte[] ciphertext1, byte[] nonce1) = sut.Encrypt(dataKey, plaintext); + (byte[] ciphertext2, byte[] nonce2) = sut.Encrypt(dataKey, plaintext); + + ciphertext1.Should().NotEqual(ciphertext2); + nonce1.Should().NotEqual(nonce2); + } + + [Fact] + public void Encrypt_EmptyString_CanBeDecrypted() + { + // Edge case: empty secrets should encrypt/decrypt cleanly. + + byte[] dataKey = sut.GenerateDataKey(); + + (byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, ""); + string recovered = sut.Decrypt(dataKey, ciphertext, nonce); + + recovered.Should().BeEmpty(); + } + + [Fact] + public void Encrypt_LargeValue_CanBeDecrypted() + { + // Secrets like certificates or multi-line configs can be large. + + byte[] dataKey = sut.GenerateDataKey(); + string large = new string('X', 100_000); + + (byte[] ciphertext, byte[] nonce) = sut.Encrypt(dataKey, large); + string recovered = sut.Decrypt(dataKey, ciphertext, nonce); + + recovered.Should().Be(large); + } +} diff --git a/tests/EntKube.Web.Tests/VaultServiceTests.cs b/tests/EntKube.Web.Tests/VaultServiceTests.cs new file mode 100644 index 0000000..78bec52 --- /dev/null +++ b/tests/EntKube.Web.Tests/VaultServiceTests.cs @@ -0,0 +1,539 @@ +using EntKube.Web.Data; +using EntKube.Web.Services; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the VaultService which manages per-tenant vaults, secrets for apps +/// and components, and Kubernetes sync configuration. Uses SQLite in-memory +/// for fast, isolated database tests. +/// +public class VaultServiceTests : IDisposable +{ + private static readonly byte[] TestRootKey = Convert.FromBase64String( + "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg="); + + private readonly SqliteConnection connection; + private readonly ApplicationDbContext db; + private readonly TestDbContextFactory dbFactory; + private readonly VaultService sut; + + public VaultServiceTests() + { + connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + db = new ApplicationDbContext(options); + dbFactory = new TestDbContextFactory(connection); + db.Database.EnsureCreated(); + + VaultEncryptionService encryption = new(TestRootKey); + sut = new VaultService(dbFactory, encryption); + } + + public void Dispose() + { + db.Dispose(); + connection.Dispose(); + } + + private Tenant CreateTenant(string name = "TestCo", string slug = "testco") + { + Tenant tenant = new() { Id = Guid.NewGuid(), Name = name, Slug = slug }; + db.Tenants.Add(tenant); + db.SaveChanges(); + return tenant; + } + + private (Tenant tenant, KubernetesCluster cluster) CreateTenantWithCluster() + { + Tenant tenant = CreateTenant(); + Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "production" }; + db.Environments.Add(env); + + KubernetesCluster cluster = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Name = "prod-cluster", + ApiServerUrl = "https://k8s.example.com" + }; + + db.KubernetesClusters.Add(cluster); + db.SaveChanges(); + return (tenant, cluster); + } + + private (Tenant tenant, App app) CreateTenantWithApp() + { + Tenant tenant = CreateTenant(); + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "CustomerA" }; + db.Customers.Add(customer); + + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "MyApp" }; + db.Apps.Add(app); + db.SaveChanges(); + return (tenant, app); + } + + // --- Vault Initialization --- + + [Fact] + public async Task InitializeVaultAsync_CreatesVaultForTenant() + { + // When a tenant is first set up, we create a vault with a sealed DEK. + + Tenant tenant = CreateTenant(); + + SecretVault vault = await sut.InitializeVaultAsync(tenant.Id); + + vault.Should().NotBeNull(); + vault.TenantId.Should().Be(tenant.Id); + vault.EncryptedDataKey.Should().NotBeEmpty(); + vault.Nonce.Should().NotBeEmpty(); + } + + [Fact] + public async Task InitializeVaultAsync_CalledTwice_ReturnsSameVault() + { + // Idempotent — if the vault already exists, return it. + + Tenant tenant = CreateTenant(); + + SecretVault first = await sut.InitializeVaultAsync(tenant.Id); + SecretVault second = await sut.InitializeVaultAsync(tenant.Id); + + second.Id.Should().Be(first.Id); + } + + [Fact] + public async Task GetVaultAsync_ReturnsNullWhenNoVaultExists() + { + SecretVault? vault = await sut.GetVaultAsync(Guid.NewGuid()); + + vault.Should().BeNull(); + } + + // --- App Secrets --- + + [Fact] + public async Task SetAppSecretAsync_CreatesNewSecret() + { + // Store a secret for a customer app. + + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "DB_PASSWORD", "s3cret!"); + + secret.Name.Should().Be("DB_PASSWORD"); + secret.AppId.Should().Be(app.Id); + secret.EncryptedValue.Should().NotBeEmpty(); + } + + [Fact] + public async Task SetAppSecretAsync_SameNameUpdatesExisting() + { + // Setting a secret with the same name should update rather than create a duplicate. + + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + await sut.SetAppSecretAsync(tenant.Id, app.Id, "API_KEY", "old-value"); + VaultSecret updated = await sut.SetAppSecretAsync(tenant.Id, app.Id, "API_KEY", "new-value"); + + // Should still be just one secret with that name. + List secrets = await sut.GetAppSecretsAsync(tenant.Id, app.Id); + secrets.Should().HaveCount(1); + secrets[0].Id.Should().Be(updated.Id); + } + + [Fact] + public async Task GetAppSecretValueAsync_DecryptsCorrectly() + { + // The decrypted value should match what was originally stored. + + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + await sut.SetAppSecretAsync(tenant.Id, app.Id, "TOKEN", "my-secret-token-123"); + + string? value = await sut.GetAppSecretValueAsync(tenant.Id, app.Id, "TOKEN"); + + value.Should().Be("my-secret-token-123"); + } + + [Fact] + public async Task GetAppSecretsAsync_ReturnsAllSecretsForApp() + { + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + await sut.SetAppSecretAsync(tenant.Id, app.Id, "SECRET_A", "val-a"); + await sut.SetAppSecretAsync(tenant.Id, app.Id, "SECRET_B", "val-b"); + + List secrets = await sut.GetAppSecretsAsync(tenant.Id, app.Id); + + secrets.Should().HaveCount(2); + secrets.Select(s => s.Name).Should().Contain(["SECRET_A", "SECRET_B"]); + } + + [Fact] + public async Task DeleteAppSecretAsync_RemovesSecret() + { + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "TEMP", "delete-me"); + bool deleted = await sut.DeleteSecretAsync(secret.Id); + + deleted.Should().BeTrue(); + List remaining = await sut.GetAppSecretsAsync(tenant.Id, app.Id); + remaining.Should().BeEmpty(); + } + + // --- Component Secrets --- + + [Fact] + public async Task SetComponentSecretAsync_CreatesNewSecret() + { + (Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster(); + await sut.InitializeVaultAsync(tenant.Id); + + ClusterComponent component = await sut.CreateComponentAsync( + cluster.Id, "minio", "HelmChart"); + + VaultSecret secret = await sut.SetComponentSecretAsync( + tenant.Id, component.Id, "MINIO_ROOT_PASSWORD", "admin123"); + + secret.Name.Should().Be("MINIO_ROOT_PASSWORD"); + secret.ComponentId.Should().Be(component.Id); + } + + [Fact] + public async Task GetComponentSecretValueAsync_DecryptsCorrectly() + { + (Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster(); + await sut.InitializeVaultAsync(tenant.Id); + + ClusterComponent component = await sut.CreateComponentAsync( + cluster.Id, "postgres", "HelmChart"); + + await sut.SetComponentSecretAsync( + tenant.Id, component.Id, "PG_PASSWORD", "postgres-secret-pw"); + + string? value = await sut.GetComponentSecretValueAsync( + tenant.Id, component.Id, "PG_PASSWORD"); + + value.Should().Be("postgres-secret-pw"); + } + + [Fact] + public async Task GetComponentSecretsAsync_ReturnsOnlyComponentSecrets() + { + (Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster(); + await sut.InitializeVaultAsync(tenant.Id); + + ClusterComponent comp = await sut.CreateComponentAsync(cluster.Id, "redis", "HelmChart"); + + await sut.SetComponentSecretAsync(tenant.Id, comp.Id, "REDIS_PASSWORD", "r3dis"); + await sut.SetComponentSecretAsync(tenant.Id, comp.Id, "REDIS_TLS_CERT", "cert-data"); + + List secrets = await sut.GetComponentSecretsAsync(tenant.Id, comp.Id); + + secrets.Should().HaveCount(2); + } + + // --- Kubernetes Sync Configuration --- + + [Fact] + public async Task ConfigureKubernetesSyncAsync_SetsFields() + { + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "DB_URL", "postgres://..."); + + await sut.ConfigureKubernetesSyncAsync( + secret.Id, syncEnabled: true, secretName: "myapp-db", ns: "production"); + + VaultSecret? reloaded = await db.VaultSecrets.FindAsync(secret.Id); + reloaded!.SyncToKubernetes.Should().BeTrue(); + reloaded.KubernetesSecretName.Should().Be("myapp-db"); + reloaded.KubernetesNamespace.Should().Be("production"); + } + + [Fact] + public async Task ConfigureKubernetesSyncAsync_DisablesSync() + { + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "KEY", "val"); + await sut.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: true, secretName: "s", ns: "ns"); + await sut.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: false, secretName: null, ns: null); + + VaultSecret? reloaded = await db.VaultSecrets.FindAsync(secret.Id); + reloaded!.SyncToKubernetes.Should().BeFalse(); + } + + // --- Cluster Components --- + + [Fact] + public async Task CreateComponentAsync_CreatesComponent() + { + (_, KubernetesCluster cluster) = CreateTenantWithCluster(); + + ClusterComponent component = await sut.CreateComponentAsync( + cluster.Id, "keycloak", "HelmChart"); + + component.Name.Should().Be("keycloak"); + component.ComponentType.Should().Be("HelmChart"); + component.ClusterId.Should().Be(cluster.Id); + } + + [Fact] + public async Task GetComponentsAsync_ReturnsComponentsForCluster() + { + (_, KubernetesCluster cluster) = CreateTenantWithCluster(); + + await sut.CreateComponentAsync(cluster.Id, "minio", "HelmChart"); + await sut.CreateComponentAsync(cluster.Id, "cnpg", "Operator"); + + List components = await sut.GetComponentsAsync(cluster.Id); + + components.Should().HaveCount(2); + } + + [Fact] + public async Task DeleteComponentAsync_RemovesComponent() + { + (_, KubernetesCluster cluster) = CreateTenantWithCluster(); + ClusterComponent component = await sut.CreateComponentAsync(cluster.Id, "temp", "Deployment"); + + bool deleted = await sut.DeleteComponentAsync(component.Id); + + deleted.Should().BeTrue(); + List remaining = await sut.GetComponentsAsync(cluster.Id); + remaining.Should().BeEmpty(); + } + + // ──────── GetSecretValueByIdAsync ──────── + + [Fact] + public async Task GetSecretValueByIdAsync_ReturnsDecryptedValue() + { + // Arrange + + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "API_KEY", "my-secret-value"); + + // Act + + string? value = await sut.GetSecretValueByIdAsync(secret.Id); + + // Assert + + value.Should().Be("my-secret-value"); + } + + [Fact] + public async Task GetSecretValueByIdAsync_NonExistent_ReturnsNull() + { + // Act + + string? value = await sut.GetSecretValueByIdAsync(Guid.NewGuid()); + + // Assert + + value.Should().BeNull(); + } + + // ──────── UpdateSecretValueAsync ──────── + + [Fact] + public async Task UpdateSecretValueAsync_UpdatesValue() + { + // Arrange + + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "DB_PASS", "old-password"); + + // Act + + bool updated = await sut.UpdateSecretValueAsync(secret.Id, "new-password"); + + // Assert + + updated.Should().BeTrue(); + string? value = await sut.GetSecretValueByIdAsync(secret.Id); + value.Should().Be("new-password"); + } + + [Fact] + public async Task UpdateSecretValueAsync_NonExistent_ReturnsFalse() + { + // Act + + bool updated = await sut.UpdateSecretValueAsync(Guid.NewGuid(), "anything"); + + // Assert + + updated.Should().BeFalse(); + } + + // ──────── CanDeleteSecretAsync ──────── + + [Fact] + public async Task CanDeleteSecretAsync_NoBindings_ReturnsTrue() + { + // Arrange + + (Tenant tenant, App app) = CreateTenantWithApp(); + await sut.InitializeVaultAsync(tenant.Id); + VaultSecret secret = await sut.SetAppSecretAsync(tenant.Id, app.Id, "TEMP", "value"); + + // Act + + (bool canDelete, string? reason) = await sut.CanDeleteSecretAsync(secret.Id); + + // Assert + + canDelete.Should().BeTrue(); + reason.Should().BeNull(); + } + + [Fact] + public async Task CanDeleteSecretAsync_WithActiveStorageBinding_ReturnsFalse() + { + // Arrange — a storage link secret that has an active binding. + + (Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster(); + await sut.InitializeVaultAsync(tenant.Id); + + Data.Environment env = await db.Environments.FirstAsync(e => e.TenantId == tenant.Id); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.AwsS3, + Name = "Backups" + }; + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + VaultSecret secret = await sut.SetStorageLinkSecretAsync(tenant.Id, link.Id, "ACCESS_KEY", "AKIA..."); + + // Create an app deployment that binds to this storage. + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Acme" }; + db.Customers.Add(customer); + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "api" }; + db.Apps.Add(app); + + AppDeployment deployment = new() + { + Id = Guid.NewGuid(), + AppId = app.Id, + Name = "api-prod", + Type = DeploymentType.HelmChart, + EnvironmentId = env.Id, + ClusterId = cluster.Id, + Namespace = "default" + }; + db.AppDeployments.Add(deployment); + + StorageBinding binding = new() + { + Id = Guid.NewGuid(), + StorageLinkId = link.Id, + AppDeploymentId = deployment.Id, + KubernetesSecretName = "backup-creds", + SyncEnabled = true + }; + db.Set().Add(binding); + await db.SaveChangesAsync(); + + // Act + + (bool canDelete, string? reason) = await sut.CanDeleteSecretAsync(secret.Id); + + // Assert + + canDelete.Should().BeFalse(); + reason.Should().Contain("storage binding"); + } + + [Fact] + public async Task CanDeleteSecretAsync_WithDisabledBinding_ReturnsTrue() + { + // Arrange — same setup but binding has SyncEnabled = false. + + (Tenant tenant, KubernetesCluster cluster) = CreateTenantWithCluster(); + await sut.InitializeVaultAsync(tenant.Id); + + Data.Environment env = await db.Environments.FirstAsync(e => e.TenantId == tenant.Id); + + StorageLink link = new() + { + Id = Guid.NewGuid(), + TenantId = tenant.Id, + EnvironmentId = env.Id, + Provider = StorageProvider.AwsS3, + Name = "Archives" + }; + db.StorageLinks.Add(link); + await db.SaveChangesAsync(); + + VaultSecret secret = await sut.SetStorageLinkSecretAsync(tenant.Id, link.Id, "SECRET_KEY", "s3cr3t"); + + Customer customer = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "Acme2" }; + db.Customers.Add(customer); + App app = new() { Id = Guid.NewGuid(), CustomerId = customer.Id, Name = "worker" }; + db.Apps.Add(app); + + AppDeployment deployment = new() + { + Id = Guid.NewGuid(), + AppId = app.Id, + Name = "worker-prod", + Type = DeploymentType.Yaml, + EnvironmentId = env.Id, + ClusterId = cluster.Id, + Namespace = "workers" + }; + db.AppDeployments.Add(deployment); + + StorageBinding binding = new() + { + Id = Guid.NewGuid(), + StorageLinkId = link.Id, + AppDeploymentId = deployment.Id, + KubernetesSecretName = "archive-creds", + SyncEnabled = false + }; + db.Set().Add(binding); + await db.SaveChangesAsync(); + + // Act + + (bool canDelete, string? reason) = await sut.CanDeleteSecretAsync(secret.Id); + + // Assert — disabled binding should not block deletion. + + canDelete.Should().BeTrue(); + reason.Should().BeNull(); + } +} diff --git a/tests/EntKube.Web.Tests/YamlFormMergerTests.cs b/tests/EntKube.Web.Tests/YamlFormMergerTests.cs new file mode 100644 index 0000000..1e5446f --- /dev/null +++ b/tests/EntKube.Web.Tests/YamlFormMergerTests.cs @@ -0,0 +1,171 @@ +using EntKube.Web.Services; +using FluentAssertions; + +namespace EntKube.Web.Tests; + +/// +/// Tests for the YamlFormMerger utility that takes form field values +/// (key-value pairs with dot-notation paths) and merges them into a +/// YAML string. This is how form-based configuration gets applied on +/// top of the raw YAML that users can also edit directly. +/// +public class YamlFormMergerTests +{ + // ──────── Simple top-level paths ──────── + + [Fact] + public void MergeFormValues_WithSimplePath_SetsValue() + { + // A form field targeting "rootUser" should set the top-level key. + + string baseYaml = """ + rootUser: admin + rootPassword: changeme + """; + + Dictionary formValues = new() { ["rootUser"] = "minio-admin" }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("rootUser: minio-admin"); + } + + [Fact] + public void MergeFormValues_WithNestedPath_SetsNestedValue() + { + // A dot-notation path like "grafana.adminPassword" should set the + // nested YAML property grafana → adminPassword. + + string baseYaml = """ + grafana: + enabled: true + adminPassword: admin + """; + + Dictionary formValues = new() { ["grafana.adminPassword"] = "s3cret!" }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("adminPassword: s3cret!"); + } + + [Fact] + public void MergeFormValues_WithDeeplyNestedPath_SetsValue() + { + // Paths can be arbitrarily deep, like "prometheus.prometheusSpec.retention". + + string baseYaml = """ + prometheus: + prometheusSpec: + retention: 15d + resources: + requests: + memory: 512Mi + """; + + Dictionary formValues = new() { ["prometheus.prometheusSpec.retention"] = "30d" }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("retention: 30d"); + result.Should().NotContain("retention: 15d"); + } + + [Fact] + public void MergeFormValues_WithEmptyBaseYaml_CreatesStructure() + { + // When the base YAML is empty, the merger should create the nested + // structure from scratch based on the dot-notation path. + + string baseYaml = ""; + + Dictionary formValues = new() { ["service.type"] = "LoadBalancer" }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("service:"); + result.Should().Contain("type: LoadBalancer"); + } + + [Fact] + public void MergeFormValues_WithMultipleValues_SetsAll() + { + // Multiple form fields should all be applied. + + string baseYaml = """ + rootUser: admin + rootPassword: changeme + persistence: + size: 50Gi + """; + + Dictionary formValues = new() + { + ["rootUser"] = "operator", + ["rootPassword"] = "strong-pass", + ["persistence.size"] = "100Gi" + }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("rootUser: operator"); + result.Should().Contain("rootPassword: strong-pass"); + result.Should().Contain("size: 100Gi"); + } + + [Fact] + public void MergeFormValues_WithNewPath_AddsToExistingStructure() + { + // If the path doesn't exist in the base YAML but parent nodes do, + // the merger should add the missing leaf without destroying siblings. + + string baseYaml = """ + grafana: + enabled: true + """; + + Dictionary formValues = new() { ["grafana.adminPassword"] = "secret" }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("enabled: true"); + result.Should().Contain("adminPassword: secret"); + } + + [Fact] + public void MergeFormValues_WithBooleanValue_WritesUnquoted() + { + // Boolean-like values (true/false) should remain unquoted in YAML. + + string baseYaml = """ + alertmanager: + enabled: true + """; + + Dictionary formValues = new() { ["alertmanager.enabled"] = "false" }; + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("enabled: false"); + result.Should().NotContain("enabled: 'false'"); + result.Should().NotContain("enabled: \"false\""); + } + + [Fact] + public void MergeFormValues_WithEmptyFormValues_ReturnsBaseYamlUnchanged() + { + // If no form values are provided, the YAML comes back untouched. + + string baseYaml = """ + rootUser: admin + rootPassword: changeme + """; + + Dictionary formValues = new(); + + string result = YamlFormMerger.MergeFormValues(baseYaml, formValues); + + result.Should().Contain("rootUser: admin"); + result.Should().Contain("rootPassword: changeme"); + } +} diff --git a/tfexamples/.DS_Store b/tfexamples/.DS_Store deleted file mode 100644 index c576473..0000000 Binary files a/tfexamples/.DS_Store and /dev/null differ diff --git a/tfexamples/CMKS - Infra b/tfexamples/CMKS - Infra deleted file mode 160000 index 58b10b9..0000000 --- a/tfexamples/CMKS - Infra +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 58b10b99c0a3949381fbcb7d89594c9427aa8b42000000000000000000000000