Add unit tests for KubernetesCluster, Tenant, ServiceInstance, and RegisterClusterHandler
- Implement tests for KubernetesCluster including registration, connectivity status, and error handling. - Create tests for Tenant creation, member management, and status changes. - Add tests for ServiceInstance provisioning and state management. - Introduce RegisterClusterHandler tests to validate registration requests and error scenarios. - Set up project files for new test projects with necessary dependencies.
This commit is contained in:
28
.gitea/workflows/build.yaml
Normal file
28
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
48
.gitea/workflows/helm.yaml
Normal file
48
.gitea/workflows/helm.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
114
.github/copilot-instructions.md
vendored
114
.github/copilot-instructions.md
vendored
@@ -400,60 +400,91 @@ If the service or project you are working on does not have a corresponding test
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Modular Monolith with Blazor
|
### Microservices with Blazor BFF
|
||||||
|
|
||||||
EntKube is a **modular monolith** — a single deployable Blazor application with clearly separated domain modules internally. This keeps deployment simple while maintaining clean boundaries between concerns.
|
EntKube follows a **microservices architecture** with a Blazor BFF (Backend-for-Frontend) as the user-facing entry point. Each service owns its bounded context, has its own data store, and can be deployed and scaled independently. This is NOT a nano-services architecture — we split by meaningful business boundaries, not by technical layers.
|
||||||
|
|
||||||
#### Core Principles
|
#### Core Principles
|
||||||
- **Domain modules within one application**: Each business capability (cluster management, service provisioning, tenant management, etc.) lives in its own namespace/folder but deploys as part of the single application
|
- **4 services, each with a clear responsibility**: Web (BFF), Clusters, Provisioning, Identity
|
||||||
- **Clear module boundaries**: Modules communicate through well-defined interfaces — never reach directly into another module's internals
|
- **Each service owns its data**: No shared databases between services
|
||||||
- **Shared database with schema separation**: The single application owns its database, but each module owns its tables/schema area
|
- **Services communicate via HTTP APIs**: Simple REST calls between services, with resilient retry policies
|
||||||
- **Extract to a service only when necessary**: If a module genuinely needs independent scaling or a separate lifecycle, extract it then — not before
|
- **SharedKernel for contracts only**: Shared types (Result, ApiResponse, base Entity) live in a shared library — but no shared business logic
|
||||||
|
- **Feature folders over layer folders**: Each feature is a vertical slice (handler + endpoint + related types in one folder)
|
||||||
|
|
||||||
#### When to Extract a Module to a Separate Service
|
#### Service Boundaries
|
||||||
- The module has drastically different scaling requirements
|
|
||||||
- The module needs to be deployed on a different cadence
|
| Service | Responsibility | Port (dev) |
|
||||||
- The module introduces an external integration that benefits from fault isolation
|
|---------|---------------|-------------|
|
||||||
|
| **EntKube.Web** | Blazor BFF — serves UI, proxies API calls to backend services, owns user auth session | 5000 |
|
||||||
|
| **EntKube.Clusters** | Kubernetes cluster registration, health monitoring, API connectivity | 5010 |
|
||||||
|
| **EntKube.Provisioning** | Shared service lifecycle (MinIO, CNPG, Keycloak) — provisioning, reconciliation, teardown | 5020 |
|
||||||
|
| **EntKube.Identity** | Tenant management, user membership, roles, Keycloak integration | 5030 |
|
||||||
|
|
||||||
#### Anti-Patterns to Avoid
|
#### Anti-Patterns to Avoid
|
||||||
```
|
```
|
||||||
# ❌ BAD: Premature microservices for a platform that deploys as one unit
|
# ❌ BAD: Nano-services — splitting too granularly
|
||||||
Services/
|
MinIOService/
|
||||||
├── ClusterService/
|
CloudNativePGService/
|
||||||
├── TenantService/
|
KeycloakService/
|
||||||
├── MonitoringService/
|
HealthCheckService/ # These belong together under "Provisioning"
|
||||||
└── ProvisioningService/ # All deployed together anyway
|
|
||||||
|
|
||||||
# ✅ GOOD: Modules within the monolith with clear boundaries
|
# ❌ BAD: Shared database between services
|
||||||
EntKube/
|
# Services must own their own data — cross-service queries go through APIs
|
||||||
├── Clusters/ # Cluster management module
|
|
||||||
├── Tenants/ # Multi-tenant module
|
# ✅ GOOD: Meaningful service boundaries with feature folders
|
||||||
├── Monitoring/ # Observability module
|
src/
|
||||||
├── Provisioning/ # Service provisioning module
|
├── EntKube.Web/ # Blazor BFF
|
||||||
└── Shared/ # Cross-cutting concerns
|
├── EntKube.Clusters/ # Cluster management service
|
||||||
|
│ ├── Domain/ # Aggregates, value objects, repository contracts
|
||||||
|
│ ├── Features/ # Vertical slices (RegisterCluster/, GetClusters/, etc.)
|
||||||
|
│ └── Infrastructure/ # Repository implementations, external integrations
|
||||||
|
├── EntKube.Provisioning/ # Service provisioning service
|
||||||
|
│ ├── Domain/
|
||||||
|
│ ├── Features/
|
||||||
|
│ └── Infrastructure/
|
||||||
|
├── EntKube.Identity/ # Identity & tenant service
|
||||||
|
│ ├── Domain/
|
||||||
|
│ ├── Features/
|
||||||
|
│ └── Infrastructure/
|
||||||
|
└── EntKube.SharedKernel/ # Shared contracts (Result, ApiResponse, base Entity)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
```
|
```
|
||||||
Solution/
|
Solution/
|
||||||
├── EntKube/ # Blazor Server host (BFF)
|
├── src/
|
||||||
│ ├── Components/ # Razor components, layouts, pages
|
│ ├── EntKube.SharedKernel/ # Shared types and contracts between services
|
||||||
│ ├── Data/ # EF Core DbContext and migrations
|
│ │ ├── Domain/ # Result, Entity base class
|
||||||
│ ├── Clusters/ # Kubernetes cluster management
|
│ │ └── Contracts/ # ApiResponse envelope, DTOs
|
||||||
│ ├── Provisioning/ # Shared service provisioning (MinIO, CNPG, Keycloak)
|
│ ├── EntKube.Web/ # Blazor Server BFF
|
||||||
│ ├── Tenants/ # Multi-tenant configuration
|
│ │ ├── Components/ # Razor components, layouts, pages
|
||||||
│ ├── Monitoring/ # Health, metrics, observability
|
│ │ ├── Data/ # EF Core DbContext (Identity only)
|
||||||
│ └── Pipelines/ # CI/CD pipeline integration
|
│ │ └── wwwroot/ # Static assets
|
||||||
├── EntKube.Client/ # Blazor WebAssembly client
|
│ ├── EntKube.Web.Client/ # Blazor WebAssembly client
|
||||||
│ ├── Pages/ # Interactive WASM pages
|
│ │ └── Pages/ # Interactive WASM pages
|
||||||
│ └── wwwroot/ # Client static assets
|
│ ├── EntKube.Clusters/ # Cluster management API
|
||||||
├── Charts/ # Helm charts for deployment
|
│ │ ├── Domain/ # KubernetesCluster aggregate
|
||||||
|
│ │ ├── Features/ # RegisterCluster/, GetClusters/
|
||||||
|
│ │ └── Infrastructure/ # Repository implementations
|
||||||
|
│ ├── EntKube.Provisioning/ # Service provisioning API
|
||||||
|
│ │ ├── Domain/ # ServiceInstance aggregate
|
||||||
|
│ │ ├── Features/ # ProvisionService/, GetServices/
|
||||||
|
│ │ └── Infrastructure/ # Repository implementations
|
||||||
|
│ └── EntKube.Identity/ # Identity & tenant API
|
||||||
|
│ ├── Domain/ # Tenant aggregate
|
||||||
|
│ ├── Features/ # CreateTenant/
|
||||||
|
│ └── Infrastructure/ # Repository implementations
|
||||||
|
├── tests/
|
||||||
|
│ ├── EntKube.Clusters.Tests/ # Unit + integration tests
|
||||||
|
│ ├── EntKube.Provisioning.Tests/
|
||||||
|
│ ├── EntKube.Identity.Tests/
|
||||||
|
│ └── EntKube.Web.Tests/
|
||||||
|
├── Charts/ # Helm charts for deployment
|
||||||
│ └── entkube/
|
│ └── entkube/
|
||||||
│ ├── Chart.yaml
|
│ ├── Chart.yaml
|
||||||
│ ├── values.yaml
|
│ ├── values.yaml
|
||||||
│ └── templates/
|
│ └── templates/
|
||||||
└── Tests/
|
└── .gitea/workflows/ # Gitea Actions CI/CD
|
||||||
└── *.Tests/ # xUnit test projects
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment & Infrastructure
|
### Deployment & Infrastructure
|
||||||
@@ -511,10 +542,11 @@ jobs:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Communication Patterns
|
### Communication Patterns
|
||||||
- Internal module communication via dependency injection and in-process calls
|
- **Service-to-service**: HTTP REST via typed HttpClient with Polly retry policies
|
||||||
- Kubernetes API communication for cluster management operations
|
- **BFF-to-service**: The Web BFF proxies user requests to the appropriate backend service
|
||||||
- HTTP APIs exposed for external integrations
|
- **Kubernetes API**: The Clusters service communicates with k8s API servers using the official .NET client
|
||||||
- Use resilient connections with retry policies (Polly) for external calls
|
- **Async workflows**: Background services within each microservice handle reconciliation loops (e.g., provisioning, health checks)
|
||||||
|
- **No message bus yet**: Start with synchronous HTTP; extract to async messaging (NATS, RabbitMQ) only when proven necessary
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
|
|||||||
4
Charts/entkube/.helmignore
Normal file
4
Charts/entkube/.helmignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Patterns to ignore when packaging the chart
|
||||||
|
.DS_Store
|
||||||
|
*.tgz
|
||||||
|
.git/
|
||||||
13
Charts/entkube/Chart.yaml
Normal file
13
Charts/entkube/Chart.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
67
Charts/entkube/templates/_helpers.tpl
Normal file
67
Charts/entkube/templates/_helpers.tpl
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{{/*
|
||||||
|
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 }}
|
||||||
51
Charts/entkube/templates/clusters-deployment.yaml
Normal file
51
Charts/entkube/templates/clusters-deployment.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{{- 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 }}
|
||||||
19
Charts/entkube/templates/clusters-service.yaml
Normal file
19
Charts/entkube/templates/clusters-service.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{- 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 }}
|
||||||
51
Charts/entkube/templates/identity-deployment.yaml
Normal file
51
Charts/entkube/templates/identity-deployment.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{{- 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 }}
|
||||||
19
Charts/entkube/templates/identity-service.yaml
Normal file
19
Charts/entkube/templates/identity-service.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{- 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 }}
|
||||||
61
Charts/entkube/templates/ingress.yaml
Normal file
61
Charts/entkube/templates/ingress.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{{- 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 }}
|
||||||
51
Charts/entkube/templates/provisioning-deployment.yaml
Normal file
51
Charts/entkube/templates/provisioning-deployment.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{{- 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 }}
|
||||||
19
Charts/entkube/templates/provisioning-service.yaml
Normal file
19
Charts/entkube/templates/provisioning-service.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{- 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 }}
|
||||||
12
Charts/entkube/templates/serviceaccount.yaml
Normal file
12
Charts/entkube/templates/serviceaccount.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{{- 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 }}
|
||||||
57
Charts/entkube/templates/web-deployment.yaml
Normal file
57
Charts/entkube/templates/web-deployment.yaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{{- 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 }}
|
||||||
19
Charts/entkube/templates/web-service.yaml
Normal file
19
Charts/entkube/templates/web-service.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{- 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 }}
|
||||||
84
Charts/entkube/values.yaml
Normal file
84
Charts/entkube/values.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# 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: {}
|
||||||
14
EntKube.slnx
14
EntKube.slnx
@@ -1,4 +1,14 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="EntKube/EntKube.Client/EntKube.Client.csproj" Id="73e36587-8940-4269-adcc-bab1e068ed1e" />
|
<!-- Source Projects -->
|
||||||
<Project Path="EntKube/EntKube/EntKube.csproj" />
|
<Project Path="src/EntKube.SharedKernel/EntKube.SharedKernel.csproj" />
|
||||||
|
<Project Path="src/EntKube.Identity/EntKube.Identity.csproj" />
|
||||||
|
<Project Path="src/EntKube.Clusters/EntKube.Clusters.csproj" />
|
||||||
|
<Project Path="src/EntKube.Provisioning/EntKube.Provisioning.csproj" />
|
||||||
|
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
|
||||||
|
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
|
||||||
|
<!-- Test Projects -->
|
||||||
|
<Project Path="tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj" />
|
||||||
|
<Project Path="tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj" />
|
||||||
|
<Project Path="tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj" />
|
||||||
|
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
|
||||||
|
|
||||||
namespace EntKube.Client
|
|
||||||
{
|
|
||||||
internal class Program
|
|
||||||
{
|
|
||||||
static async Task Main(string[] args)
|
|
||||||
{
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
|
||||||
|
|
||||||
builder.Services.AddAuthorizationCore();
|
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
|
||||||
builder.Services.AddAuthenticationStateDeserialization();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
using EntKube.Components.Account.Pages;
|
|
||||||
using EntKube.Components.Account.Pages.Manage;
|
|
||||||
using EntKube.Data;
|
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.Routing
|
|
||||||
{
|
|
||||||
internal static class IdentityComponentsEndpointRouteBuilderExtensions
|
|
||||||
{
|
|
||||||
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
|
|
||||||
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(endpoints);
|
|
||||||
|
|
||||||
var accountGroup = endpoints.MapGroup("/Account");
|
|
||||||
|
|
||||||
accountGroup.MapPost("/PerformExternalLogin", (
|
|
||||||
HttpContext context,
|
|
||||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
|
||||||
[FromForm] string provider,
|
|
||||||
[FromForm] string returnUrl) =>
|
|
||||||
{
|
|
||||||
IEnumerable<KeyValuePair<string, StringValues>> query = [
|
|
||||||
new("ReturnUrl", returnUrl),
|
|
||||||
new("Action", ExternalLogin.LoginCallbackAction)];
|
|
||||||
|
|
||||||
var redirectUrl = UriHelper.BuildRelative(
|
|
||||||
context.Request.PathBase,
|
|
||||||
"/Account/ExternalLogin",
|
|
||||||
QueryString.Create(query));
|
|
||||||
|
|
||||||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
|
||||||
return TypedResults.Challenge(properties, [provider]);
|
|
||||||
});
|
|
||||||
|
|
||||||
accountGroup.MapPost("/Logout", async (
|
|
||||||
ClaimsPrincipal user,
|
|
||||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
|
||||||
[FromForm] string returnUrl) =>
|
|
||||||
{
|
|
||||||
await signInManager.SignOutAsync();
|
|
||||||
return TypedResults.LocalRedirect($"~/{returnUrl}");
|
|
||||||
});
|
|
||||||
|
|
||||||
accountGroup.MapPost("/PasskeyCreationOptions", async (
|
|
||||||
HttpContext context,
|
|
||||||
[FromServices] UserManager<ApplicationUser> userManager,
|
|
||||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
|
||||||
[FromServices] IAntiforgery antiforgery) =>
|
|
||||||
{
|
|
||||||
await antiforgery.ValidateRequestAsync(context);
|
|
||||||
|
|
||||||
var user = await userManager.GetUserAsync(context.User);
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId = await userManager.GetUserIdAsync(user);
|
|
||||||
var userName = await userManager.GetUserNameAsync(user) ?? "User";
|
|
||||||
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
|
|
||||||
{
|
|
||||||
Id = userId,
|
|
||||||
Name = userName,
|
|
||||||
DisplayName = userName
|
|
||||||
});
|
|
||||||
return TypedResults.Content(optionsJson, contentType: "application/json");
|
|
||||||
});
|
|
||||||
|
|
||||||
accountGroup.MapPost("/PasskeyRequestOptions", async (
|
|
||||||
HttpContext context,
|
|
||||||
[FromServices] UserManager<ApplicationUser> userManager,
|
|
||||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
|
||||||
[FromServices] IAntiforgery antiforgery,
|
|
||||||
[FromQuery] string? username) =>
|
|
||||||
{
|
|
||||||
await antiforgery.ValidateRequestAsync(context);
|
|
||||||
|
|
||||||
var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
|
|
||||||
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
|
|
||||||
return TypedResults.Content(optionsJson, contentType: "application/json");
|
|
||||||
});
|
|
||||||
|
|
||||||
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
|
|
||||||
|
|
||||||
manageGroup.MapPost("/LinkExternalLogin", async (
|
|
||||||
HttpContext context,
|
|
||||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
|
||||||
[FromForm] string provider) =>
|
|
||||||
{
|
|
||||||
// Clear the existing external cookie to ensure a clean login process
|
|
||||||
await context.SignOutAsync(IdentityConstants.ExternalScheme);
|
|
||||||
|
|
||||||
var redirectUrl = UriHelper.BuildRelative(
|
|
||||||
context.Request.PathBase,
|
|
||||||
"/Account/Manage/ExternalLogins",
|
|
||||||
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
|
|
||||||
|
|
||||||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
|
|
||||||
return TypedResults.Challenge(properties, [provider]);
|
|
||||||
});
|
|
||||||
|
|
||||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
|
||||||
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
|
|
||||||
|
|
||||||
manageGroup.MapPost("/DownloadPersonalData", async (
|
|
||||||
HttpContext context,
|
|
||||||
[FromServices] UserManager<ApplicationUser> userManager,
|
|
||||||
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
|
|
||||||
{
|
|
||||||
var user = await userManager.GetUserAsync(context.User);
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId = await userManager.GetUserIdAsync(user);
|
|
||||||
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
|
|
||||||
|
|
||||||
// Only include personal data for download
|
|
||||||
var personalData = new Dictionary<string, string>();
|
|
||||||
var personalDataProps = typeof(ApplicationUser).GetProperties().Where(
|
|
||||||
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
|
|
||||||
foreach (var p in personalDataProps)
|
|
||||||
{
|
|
||||||
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
|
|
||||||
}
|
|
||||||
|
|
||||||
var logins = await userManager.GetLoginsAsync(user);
|
|
||||||
foreach (var l in logins)
|
|
||||||
{
|
|
||||||
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
|
|
||||||
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
|
|
||||||
|
|
||||||
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
|
|
||||||
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
return accountGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using EntKube.Data;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
|
|
||||||
namespace EntKube.Components.Account
|
|
||||||
{
|
|
||||||
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
|
|
||||||
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
|
|
||||||
{
|
|
||||||
private readonly IEmailSender emailSender = new NoOpEmailSender();
|
|
||||||
|
|
||||||
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
|
|
||||||
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
|
|
||||||
|
|
||||||
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
|
|
||||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
|
|
||||||
|
|
||||||
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
|
|
||||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using EntKube.Data;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
|
|
||||||
namespace EntKube.Components.Account
|
|
||||||
{
|
|
||||||
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
|
|
||||||
{
|
|
||||||
public const string StatusCookieName = "Identity.StatusMessage";
|
|
||||||
|
|
||||||
private static readonly CookieBuilder StatusCookieBuilder = new()
|
|
||||||
{
|
|
||||||
SameSite = SameSiteMode.Strict,
|
|
||||||
HttpOnly = true,
|
|
||||||
IsEssential = true,
|
|
||||||
MaxAge = TimeSpan.FromSeconds(5),
|
|
||||||
};
|
|
||||||
|
|
||||||
public void RedirectTo(string? uri)
|
|
||||||
{
|
|
||||||
uri ??= "";
|
|
||||||
|
|
||||||
// Prevent open redirects.
|
|
||||||
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
|
|
||||||
{
|
|
||||||
uri = navigationManager.ToBaseRelativePath(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationManager.NavigateTo(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
|
|
||||||
{
|
|
||||||
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
|
||||||
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
|
||||||
RedirectTo(newUri);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RedirectToWithStatus(string uri, string message, HttpContext context)
|
|
||||||
{
|
|
||||||
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
|
|
||||||
RedirectTo(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
|
||||||
|
|
||||||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
|
||||||
|
|
||||||
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
|
|
||||||
=> RedirectToWithStatus(CurrentPath, message, context);
|
|
||||||
|
|
||||||
public void RedirectToInvalidUser(UserManager<ApplicationUser> userManager, HttpContext context)
|
|
||||||
=> RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using EntKube.Data;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Components.Server;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace EntKube.Components.Account
|
|
||||||
{
|
|
||||||
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
|
||||||
// every 30 minutes an interactive circuit is connected.
|
|
||||||
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
|
|
||||||
ILoggerFactory loggerFactory,
|
|
||||||
IServiceScopeFactory scopeFactory,
|
|
||||||
IOptions<IdentityOptions> options)
|
|
||||||
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
|
||||||
{
|
|
||||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
|
||||||
|
|
||||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
|
||||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Get the user manager from a new scope to ensure it fetches fresh data
|
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
|
||||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
||||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
|
|
||||||
{
|
|
||||||
var user = await userManager.GetUserAsync(principal);
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else if (!userManager.SupportsUserSecurityStamp)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
|
|
||||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
|
||||||
return principalStamp == userStamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
@using EntKube.Components.Account.Shared
|
|
||||||
@attribute [ExcludeFromInteractiveRouting]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace EntKube.Components.Account
|
|
||||||
{
|
|
||||||
public class PasskeyInputModel
|
|
||||||
{
|
|
||||||
public string? CredentialJson { get; set; }
|
|
||||||
public string? Error { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace EntKube.Components.Account
|
|
||||||
{
|
|
||||||
public enum PasskeyOperation
|
|
||||||
{
|
|
||||||
Create = 0,
|
|
||||||
Request = 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EntKube.Data
|
|
||||||
{
|
|
||||||
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
|
|
||||||
namespace EntKube.Data
|
|
||||||
{
|
|
||||||
// Add profile data for application users by adding properties to the ApplicationUser class
|
|
||||||
public class ApplicationUser : IdentityUser
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using EntKube.Client.Pages;
|
|
||||||
using EntKube.Components;
|
|
||||||
using EntKube.Components.Account;
|
|
||||||
using EntKube.Data;
|
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EntKube
|
|
||||||
{
|
|
||||||
public class Program
|
|
||||||
{
|
|
||||||
public static void Main(string[] args)
|
|
||||||
{
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Add services to the container.
|
|
||||||
builder.Services.AddRazorComponents()
|
|
||||||
.AddInteractiveServerComponents()
|
|
||||||
.AddInteractiveWebAssemblyComponents()
|
|
||||||
.AddAuthenticationStateSerialization();
|
|
||||||
|
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
|
||||||
builder.Services.AddScoped<IdentityRedirectManager>();
|
|
||||||
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>();
|
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
|
||||||
{
|
|
||||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
|
||||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
|
||||||
})
|
|
||||||
.AddIdentityCookies();
|
|
||||||
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
|
||||||
options.UseSqlite(connectionString));
|
|
||||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
|
||||||
|
|
||||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
|
||||||
{
|
|
||||||
options.SignIn.RequireConfirmedAccount = true;
|
|
||||||
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
|
|
||||||
})
|
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
|
||||||
.AddSignInManager()
|
|
||||||
.AddDefaultTokenProviders();
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseWebAssemblyDebugging();
|
|
||||||
app.UseMigrationsEndPoint();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseAntiforgery();
|
|
||||||
|
|
||||||
app.MapStaticAssets();
|
|
||||||
app.MapRazorComponents<App>()
|
|
||||||
.AddInteractiveServerRenderMode()
|
|
||||||
.AddInteractiveWebAssemblyRenderMode()
|
|
||||||
.AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
|
|
||||||
|
|
||||||
// Add additional endpoints required by the Identity /Account Razor components.
|
|
||||||
app.MapAdditionalIdentityEndpoints();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"mssql1": {
|
|
||||||
"type": "mssql",
|
|
||||||
"connectionId": "ConnectionStrings:DefaultConnection"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"mssql1": {
|
|
||||||
"type": "mssql.local",
|
|
||||||
"connectionId": "ConnectionStrings:DefaultConnection"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
src/EntKube.Clusters/Domain/IClusterRepository.cs
Normal file
15
src/EntKube.Clusters/Domain/IClusterRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace EntKube.Clusters.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines how the cluster service persists and retrieves cluster aggregates.
|
||||||
|
/// The implementation lives in the infrastructure layer — the domain only
|
||||||
|
/// knows about this contract, keeping it persistence-agnostic.
|
||||||
|
/// </summary>
|
||||||
|
public interface IClusterRepository
|
||||||
|
{
|
||||||
|
Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
76
src/EntKube.Clusters/Domain/KubernetesCluster.cs
Normal file
76
src/EntKube.Clusters/Domain/KubernetesCluster.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
namespace EntKube.Clusters.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A KubernetesCluster represents a registered cluster that EntKube manages.
|
||||||
|
/// It holds the connection details, health state, and metadata needed to
|
||||||
|
/// interact with the cluster's API server. This is the aggregate root for
|
||||||
|
/// all cluster-related operations.
|
||||||
|
/// </summary>
|
||||||
|
public class KubernetesCluster
|
||||||
|
{
|
||||||
|
public Guid Id { get; private set; }
|
||||||
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
public string ApiServerUrl { get; private set; } = string.Empty;
|
||||||
|
public ClusterStatus Status { get; private set; }
|
||||||
|
public string? KubeConfigSecret { get; private set; }
|
||||||
|
public DateTimeOffset RegisteredAt { get; private set; }
|
||||||
|
public DateTimeOffset? LastHealthCheckAt { get; private set; }
|
||||||
|
|
||||||
|
private KubernetesCluster() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new cluster in the platform. At this point, we know the cluster
|
||||||
|
/// exists and we have connection details — but we haven't verified connectivity yet.
|
||||||
|
/// The cluster starts in a Pending state until a health check confirms it's reachable.
|
||||||
|
/// </summary>
|
||||||
|
public static KubernetesCluster Register(string name, string apiServerUrl, string? kubeConfigSecret)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new KubernetesCluster
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = name,
|
||||||
|
ApiServerUrl = apiServerUrl,
|
||||||
|
KubeConfigSecret = kubeConfigSecret,
|
||||||
|
Status = ClusterStatus.Pending,
|
||||||
|
RegisteredAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After a successful health check, we mark the cluster as connected.
|
||||||
|
/// This means the platform can now schedule work against this cluster.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkConnected()
|
||||||
|
{
|
||||||
|
Status = ClusterStatus.Connected;
|
||||||
|
LastHealthCheckAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When a health check fails, we mark the cluster as unreachable.
|
||||||
|
/// Existing workloads keep running, but no new provisioning can happen
|
||||||
|
/// until connectivity is restored.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkUnreachable()
|
||||||
|
{
|
||||||
|
Status = ClusterStatus.Unreachable;
|
||||||
|
LastHealthCheckAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ClusterStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Connected,
|
||||||
|
Unreachable
|
||||||
|
}
|
||||||
17
src/EntKube.Clusters/EntKube.Clusters.csproj
Normal file
17
src/EntKube.Clusters/EntKube.Clusters.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
src/EntKube.Clusters/EntKube.Clusters.http
Normal file
6
src/EntKube.Clusters/EntKube.Clusters.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@EntKube.Clusters_HostAddress = http://localhost:5243
|
||||||
|
|
||||||
|
GET {{EntKube.Clusters_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using EntKube.Clusters.Domain;
|
||||||
|
using EntKube.SharedKernel.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EntKube.Clusters.Features.GetClusters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class GetClustersEndpoint
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/clusters", async (
|
||||||
|
[FromServices] IClusterRepository repository,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<KubernetesCluster> clusters = await repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
List<ClusterSummary> summaries = clusters.Select(c => new ClusterSummary(
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.ApiServerUrl,
|
||||||
|
c.Status.ToString(),
|
||||||
|
c.LastHealthCheckAt)).ToList();
|
||||||
|
|
||||||
|
return Results.Ok(ApiResponse<List<ClusterSummary>>.Ok(summaries));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClusterSummary(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
string ApiServerUrl,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset? LastHealthCheckAt);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using EntKube.Clusters.Domain;
|
||||||
|
using EntKube.SharedKernel.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EntKube.Clusters.Features.RegisterCluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the HTTP POST /api/clusters endpoint. Receives a registration request,
|
||||||
|
/// delegates to the handler, and returns the new cluster's ID on success.
|
||||||
|
/// </summary>
|
||||||
|
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<Guid> result = await handler.HandleAsync(request, ct);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Created($"/api/clusters/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using EntKube.Clusters.Domain;
|
||||||
|
using EntKube.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace EntKube.Clusters.Features.RegisterCluster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the registration of a new Kubernetes cluster into the platform.
|
||||||
|
/// A tenant admin provides the cluster name, API server URL, and optionally
|
||||||
|
/// a kubeconfig secret reference. 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.
|
||||||
|
/// </summary>
|
||||||
|
public class RegisterClusterHandler
|
||||||
|
{
|
||||||
|
private readonly IClusterRepository repository;
|
||||||
|
|
||||||
|
public RegisterClusterHandler(IClusterRepository repository)
|
||||||
|
{
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Guid>> HandleAsync(RegisterClusterRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Validate that the caller provided the minimum required information.
|
||||||
|
// Without a name and API URL, we cannot register a cluster.
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("Cluster name is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ApiServerUrl))
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("API server URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.KubeConfigSecret);
|
||||||
|
|
||||||
|
// 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? KubeConfigSecret);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using EntKube.Clusters.Domain;
|
||||||
|
|
||||||
|
namespace EntKube.Clusters.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class InMemoryClusterRepository : IClusterRepository
|
||||||
|
{
|
||||||
|
private readonly List<KubernetesCluster> clusters = new();
|
||||||
|
|
||||||
|
public Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
KubernetesCluster? cluster = clusters.FirstOrDefault(c => c.Id == id);
|
||||||
|
return Task.FromResult(cluster);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IReadOnlyList<KubernetesCluster> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/EntKube.Clusters/Program.cs
Normal file
39
src/EntKube.Clusters/Program.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using EntKube.Clusters.Domain;
|
||||||
|
using EntKube.Clusters.Features.GetClusters;
|
||||||
|
using EntKube.Clusters.Features.RegisterCluster;
|
||||||
|
using EntKube.Clusters.Infrastructure;
|
||||||
|
|
||||||
|
namespace EntKube.Clusters;
|
||||||
|
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Register application services. The cluster repository is a singleton
|
||||||
|
// for the in-memory implementation — production will use a scoped EF context.
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IClusterRepository, InMemoryClusterRepository>();
|
||||||
|
builder.Services.AddScoped<RegisterClusterHandler>();
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/EntKube.Clusters/Properties/launchSettings.json
Normal file
23
src/EntKube.Clusters/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5243",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7267;http://localhost:5243",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/EntKube.Clusters/appsettings.json
Normal file
9
src/EntKube.Clusters/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
13
src/EntKube.Identity/Domain/ITenantRepository.cs
Normal file
13
src/EntKube.Identity/Domain/ITenantRepository.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EntKube.Identity.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines how the identity service persists and retrieves tenants.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITenantRepository
|
||||||
|
{
|
||||||
|
Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<Tenant?> GetBySlugAsync(string slug, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task AddAsync(Tenant tenant, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Tenant tenant, CancellationToken ct = default);
|
||||||
|
}
|
||||||
91
src/EntKube.Identity/Domain/Tenant.cs
Normal file
91
src/EntKube.Identity/Domain/Tenant.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
namespace EntKube.Identity.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<TenantMember> members = new();
|
||||||
|
public IReadOnlyList<TenantMember> Members => members.AsReadOnly();
|
||||||
|
|
||||||
|
private Tenant() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new tenant in the platform. The creating user automatically
|
||||||
|
/// becomes the tenant's first admin member.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a user to this tenant with the specified role.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
17
src/EntKube.Identity/EntKube.Identity.csproj
Normal file
17
src/EntKube.Identity/EntKube.Identity.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
src/EntKube.Identity/EntKube.Identity.http
Normal file
6
src/EntKube.Identity/EntKube.Identity.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@EntKube.Identity_HostAddress = http://localhost:5076
|
||||||
|
|
||||||
|
GET {{EntKube.Identity_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using EntKube.SharedKernel.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EntKube.Identity.Features.CreateTenant;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps POST /api/tenants — creates a new tenant organization.
|
||||||
|
/// </summary>
|
||||||
|
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<Guid> result = await handler.HandleAsync(request, ct);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Created($"/api/tenants/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using EntKube.Identity.Domain;
|
||||||
|
using EntKube.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace EntKube.Identity.Features.CreateTenant;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateTenantHandler
|
||||||
|
{
|
||||||
|
private readonly ITenantRepository repository;
|
||||||
|
|
||||||
|
public CreateTenantHandler(ITenantRepository repository)
|
||||||
|
{
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Guid>> HandleAsync(CreateTenantRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Validate required fields.
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("Tenant name is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Slug))
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("Tenant slug is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.CreatingUserId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("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<Guid>($"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);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using EntKube.Identity.Domain;
|
||||||
|
|
||||||
|
namespace EntKube.Identity.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of the tenant repository for local development.
|
||||||
|
/// Production will use EF Core with PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public class InMemoryTenantRepository : ITenantRepository
|
||||||
|
{
|
||||||
|
private readonly List<Tenant> tenants = new();
|
||||||
|
|
||||||
|
public Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Tenant? tenant = tenants.FirstOrDefault(t => t.Id == id);
|
||||||
|
return Task.FromResult(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Tenant?> GetBySlugAsync(string slug, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Tenant? tenant = tenants.FirstOrDefault(t => t.Slug == slug);
|
||||||
|
return Task.FromResult(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IReadOnlyList<Tenant> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/EntKube.Identity/Program.cs
Normal file
35
src/EntKube.Identity/Program.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using EntKube.Identity.Domain;
|
||||||
|
using EntKube.Identity.Features.CreateTenant;
|
||||||
|
using EntKube.Identity.Infrastructure;
|
||||||
|
|
||||||
|
namespace EntKube.Identity;
|
||||||
|
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Register identity and tenant services. In production, this will also
|
||||||
|
// integrate with Keycloak for authentication token validation and user management.
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<ITenantRepository, InMemoryTenantRepository>();
|
||||||
|
builder.Services.AddScoped<CreateTenantHandler>();
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Map feature endpoints.
|
||||||
|
|
||||||
|
CreateTenantEndpoint.Map(app);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/EntKube.Identity/Properties/launchSettings.json
Normal file
23
src/EntKube.Identity/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5076",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7194;http://localhost:5076",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/EntKube.Identity/appsettings.json
Normal file
9
src/EntKube.Identity/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EntKube.Provisioning.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines how the provisioning service persists and retrieves service instances.
|
||||||
|
/// </summary>
|
||||||
|
public interface IServiceInstanceRepository
|
||||||
|
{
|
||||||
|
Task<ServiceInstance?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<ServiceInstance>> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<ServiceInstance>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task AddAsync(ServiceInstance instance, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default);
|
||||||
|
}
|
||||||
95
src/EntKube.Provisioning/Domain/ServiceInstance.cs
Normal file
95
src/EntKube.Provisioning/Domain/ServiceInstance.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
namespace EntKube.Provisioning.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ServiceInstance represents a shared Kubernetes application (MinIO, CloudNativePG,
|
||||||
|
/// Keycloak, etc.) that has been provisioned on a specific cluster. It tracks the
|
||||||
|
/// service type, desired state, current state, and the cluster it's deployed to.
|
||||||
|
/// This is the aggregate root for provisioning operations.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceInstance
|
||||||
|
{
|
||||||
|
public Guid Id { 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() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests provisioning of a new shared service on a given cluster.
|
||||||
|
/// The service starts in a Pending state — the reconciliation loop will
|
||||||
|
/// pick it up and deploy the necessary Helm chart or Kubernetes resources.
|
||||||
|
/// </summary>
|
||||||
|
public static ServiceInstance Provision(Guid clusterId, ServiceType serviceType, string name, string ns)
|
||||||
|
{
|
||||||
|
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(),
|
||||||
|
ClusterId = clusterId,
|
||||||
|
ServiceType = serviceType,
|
||||||
|
Name = name,
|
||||||
|
Namespace = ns,
|
||||||
|
DesiredState = ServiceState.Running,
|
||||||
|
CurrentState = ServiceState.Pending,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reconciliation loop calls this after successfully deploying or verifying
|
||||||
|
/// the service is running on the cluster.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkRunning()
|
||||||
|
{
|
||||||
|
CurrentState = ServiceState.Running;
|
||||||
|
LastReconcileAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the reconciliation loop detects the service is degraded or unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkDegraded()
|
||||||
|
{
|
||||||
|
CurrentState = ServiceState.Degraded;
|
||||||
|
LastReconcileAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tenant requests decommissioning of the service. We set the desired state
|
||||||
|
/// to Decommissioned and the reconciliation loop will handle teardown.
|
||||||
|
/// </summary>
|
||||||
|
public void RequestDecommission()
|
||||||
|
{
|
||||||
|
DesiredState = ServiceState.Decommissioned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ServiceType
|
||||||
|
{
|
||||||
|
MinIO,
|
||||||
|
CloudNativePG,
|
||||||
|
Keycloak
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ServiceState
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Running,
|
||||||
|
Degraded,
|
||||||
|
Decommissioned
|
||||||
|
}
|
||||||
17
src/EntKube.Provisioning/EntKube.Provisioning.csproj
Normal file
17
src/EntKube.Provisioning/EntKube.Provisioning.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
src/EntKube.Provisioning/EntKube.Provisioning.http
Normal file
6
src/EntKube.Provisioning/EntKube.Provisioning.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@EntKube.Provisioning_HostAddress = http://localhost:5260
|
||||||
|
|
||||||
|
GET {{EntKube.Provisioning_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using EntKube.Provisioning.Domain;
|
||||||
|
using EntKube.SharedKernel.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EntKube.Provisioning.Features.GetServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all provisioned service instances. Optionally filtered by cluster.
|
||||||
|
/// </summary>
|
||||||
|
public static class GetServicesEndpoint
|
||||||
|
{
|
||||||
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/services", async (
|
||||||
|
[FromQuery] Guid? clusterId,
|
||||||
|
[FromServices] IServiceInstanceRepository repository,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<ServiceInstance> instances = clusterId.HasValue
|
||||||
|
? await repository.GetByClusterIdAsync(clusterId.Value, ct)
|
||||||
|
: await repository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
List<ServiceSummary> 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<List<ServiceSummary>>.Ok(summaries));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ServiceSummary(
|
||||||
|
Guid Id,
|
||||||
|
Guid ClusterId,
|
||||||
|
string ServiceType,
|
||||||
|
string Name,
|
||||||
|
string Namespace,
|
||||||
|
string CurrentState,
|
||||||
|
string DesiredState);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using EntKube.Provisioning.Domain;
|
||||||
|
using EntKube.SharedKernel.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EntKube.Provisioning.Features.ProvisionService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps POST /api/services — creates a new provisioning request for a shared service.
|
||||||
|
/// </summary>
|
||||||
|
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<Guid> result = await handler.HandleAsync(request, ct);
|
||||||
|
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Created($"/api/services/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using EntKube.Provisioning.Domain;
|
||||||
|
using EntKube.SharedKernel.Domain;
|
||||||
|
|
||||||
|
namespace EntKube.Provisioning.Features.ProvisionService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class ProvisionServiceHandler
|
||||||
|
{
|
||||||
|
private readonly IServiceInstanceRepository repository;
|
||||||
|
|
||||||
|
public ProvisionServiceHandler(IServiceInstanceRepository repository)
|
||||||
|
{
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Guid>> HandleAsync(ProvisionServiceRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Validate the request contains all required fields.
|
||||||
|
|
||||||
|
if (request.ClusterId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("Cluster ID is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("Service name is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Namespace))
|
||||||
|
{
|
||||||
|
return Result.Failure<Guid>("Namespace is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the service instance aggregate. The domain enforces any invariants
|
||||||
|
// about valid service configurations.
|
||||||
|
|
||||||
|
ServiceInstance instance = ServiceInstance.Provision(
|
||||||
|
request.ClusterId,
|
||||||
|
request.ServiceType,
|
||||||
|
request.Name,
|
||||||
|
request.Namespace);
|
||||||
|
|
||||||
|
// 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 ClusterId,
|
||||||
|
ServiceType ServiceType,
|
||||||
|
string Name,
|
||||||
|
string Namespace);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using EntKube.Provisioning.Domain;
|
||||||
|
|
||||||
|
namespace EntKube.Provisioning.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of the service instance repository for local development.
|
||||||
|
/// Production will use EF Core with PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public class InMemoryServiceInstanceRepository : IServiceInstanceRepository
|
||||||
|
{
|
||||||
|
private readonly List<ServiceInstance> instances = new();
|
||||||
|
|
||||||
|
public Task<ServiceInstance?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ServiceInstance? instance = instances.FirstOrDefault(i => i.Id == id);
|
||||||
|
return Task.FromResult(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ServiceInstance>> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ServiceInstance> result = instances.Where(i => i.ClusterId == clusterId).ToList().AsReadOnly();
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ServiceInstance>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ServiceInstance> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/EntKube.Provisioning/Program.cs
Normal file
37
src/EntKube.Provisioning/Program.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using EntKube.Provisioning.Domain;
|
||||||
|
using EntKube.Provisioning.Features.GetServices;
|
||||||
|
using EntKube.Provisioning.Features.ProvisionService;
|
||||||
|
using EntKube.Provisioning.Infrastructure;
|
||||||
|
|
||||||
|
namespace EntKube.Provisioning;
|
||||||
|
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Register provisioning services. The repository is singleton for the in-memory
|
||||||
|
// implementation; production will use a scoped DbContext-backed repository.
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IServiceInstanceRepository, InMemoryServiceInstanceRepository>();
|
||||||
|
builder.Services.AddScoped<ProvisionServiceHandler>();
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapOpenApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Map feature endpoints.
|
||||||
|
|
||||||
|
ProvisionServiceEndpoint.Map(app);
|
||||||
|
GetServicesEndpoint.Map(app);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/EntKube.Provisioning/Properties/launchSettings.json
Normal file
23
src/EntKube.Provisioning/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5260",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7085;http://localhost:5260",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/EntKube.Provisioning/appsettings.json
Normal file
9
src/EntKube.Provisioning/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
26
src/EntKube.SharedKernel/Contracts/ApiResponse.cs
Normal file
26
src/EntKube.SharedKernel/Contracts/ApiResponse.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace EntKube.SharedKernel.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public record ApiResponse<T>
|
||||||
|
{
|
||||||
|
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<T> Ok(T data) => new()
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = data
|
||||||
|
};
|
||||||
|
|
||||||
|
public static ApiResponse<T> Fail(string error) => new()
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = error
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/EntKube.SharedKernel/Domain/Entity.cs
Normal file
24
src/EntKube.SharedKernel/Domain/Entity.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace EntKube.SharedKernel.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class Entity<TId> where TId : notnull
|
||||||
|
{
|
||||||
|
public TId Id { get; protected set; } = default!;
|
||||||
|
|
||||||
|
public override bool Equals(object? obj)
|
||||||
|
{
|
||||||
|
if (obj is not Entity<TId> other)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Id.Equals(other.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode() => Id.GetHashCode();
|
||||||
|
}
|
||||||
43
src/EntKube.SharedKernel/Domain/Result.cs
Normal file
43
src/EntKube.SharedKernel/Domain/Result.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace EntKube.SharedKernel.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<T> Success<T>(T value) => new(value, true, null);
|
||||||
|
|
||||||
|
public static Result<T> Failure<T>(string error) => new(default, false, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class Result<T> : Result
|
||||||
|
{
|
||||||
|
public T? Value { get; }
|
||||||
|
|
||||||
|
internal Result(T? value, bool isSuccess, string? error)
|
||||||
|
: base(isSuccess, error)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/EntKube.SharedKernel/EntKube.SharedKernel.csproj
Normal file
9
src/EntKube.SharedKernel/EntKube.SharedKernel.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="">EntKube</a>
|
<a class="navbar-brand" href="">EntKube.Web</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
|
||||||
|
|
||||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||||
<div class="components-reconnect-container">
|
<div class="components-reconnect-container">
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
<p class="components-pause-visible">
|
<p class="components-pause-visible">
|
||||||
The session has been paused by the server.
|
The session has been paused by the server.
|
||||||
</p>
|
</p>
|
||||||
<p class="components-resume-failed-visible">
|
<button id="components-resume-button" class="components-pause-visible">
|
||||||
Failed to resume the session.<br />Please retry or reload the page.
|
|
||||||
</p>
|
|
||||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</button>
|
||||||
|
<p class="components-resume-failed-visible">
|
||||||
|
Failed to resume the session.<br />Please reload the page.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -52,7 +52,7 @@ async function resume() {
|
|||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@rendermode InteractiveAuto
|
|
||||||
|
|
||||||
<PageTitle>Auth</PageTitle>
|
<PageTitle>Auth</PageTitle>
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/counter"
|
@page "/counter"
|
||||||
@rendermode InteractiveAuto
|
|
||||||
|
|
||||||
<PageTitle>Counter</PageTitle>
|
<PageTitle>Counter</PageTitle>
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/weather"
|
@page "/weather"
|
||||||
@attribute [StreamRendering]
|
|
||||||
|
|
||||||
<PageTitle>Weather</PageTitle>
|
<PageTitle>Weather</PageTitle>
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ else
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
// Simulate asynchronous loading to demonstrate a loading indicator
|
||||||
await Task.Delay(500);
|
await Task.Delay(500);
|
||||||
|
|
||||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
||||||
9
src/EntKube.Web.Client/Program.cs
Normal file
9
src/EntKube.Web.Client/Program.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddAuthenticationStateDeserialization();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }" NotFoundPage="typeof(Pages.NotFound)">
|
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -7,4 +7,5 @@
|
|||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using EntKube.Client
|
@using EntKube.Web.Client
|
||||||
|
@using EntKube.Web.Client.Layout
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/EntKube.Web.Client/wwwroot/appsettings.json
Normal file
8
src/EntKube.Web.Client/wwwroot/appsettings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http.Extensions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using EntKube.Web.Components.Account.Pages;
|
||||||
|
using EntKube.Web.Components.Account.Pages.Manage;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
internal static class IdentityComponentsEndpointRouteBuilderExtensions
|
||||||
|
{
|
||||||
|
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
|
||||||
|
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(endpoints);
|
||||||
|
|
||||||
|
var accountGroup = endpoints.MapGroup("/Account");
|
||||||
|
|
||||||
|
accountGroup.MapPost("/PerformExternalLogin", (
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||||
|
[FromForm] string provider,
|
||||||
|
[FromForm] string returnUrl) =>
|
||||||
|
{
|
||||||
|
IEnumerable<KeyValuePair<string, StringValues>> query = [
|
||||||
|
new("ReturnUrl", returnUrl),
|
||||||
|
new("Action", ExternalLogin.LoginCallbackAction)];
|
||||||
|
|
||||||
|
var redirectUrl = UriHelper.BuildRelative(
|
||||||
|
context.Request.PathBase,
|
||||||
|
"/Account/ExternalLogin",
|
||||||
|
QueryString.Create(query));
|
||||||
|
|
||||||
|
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
||||||
|
return TypedResults.Challenge(properties, [provider]);
|
||||||
|
});
|
||||||
|
|
||||||
|
accountGroup.MapPost("/Logout", async (
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||||
|
[FromForm] string returnUrl) =>
|
||||||
|
{
|
||||||
|
await signInManager.SignOutAsync();
|
||||||
|
return TypedResults.LocalRedirect($"~/{returnUrl}");
|
||||||
|
});
|
||||||
|
|
||||||
|
accountGroup.MapPost("/PasskeyCreationOptions", async (
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] UserManager<ApplicationUser> userManager,
|
||||||
|
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||||
|
[FromServices] IAntiforgery antiforgery) =>
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(context);
|
||||||
|
|
||||||
|
var user = await userManager.GetUserAsync(context.User);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = await userManager.GetUserIdAsync(user);
|
||||||
|
var userName = await userManager.GetUserNameAsync(user) ?? "User";
|
||||||
|
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
|
||||||
|
{
|
||||||
|
Id = userId,
|
||||||
|
Name = userName,
|
||||||
|
DisplayName = userName
|
||||||
|
});
|
||||||
|
return TypedResults.Content(optionsJson, contentType: "application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
accountGroup.MapPost("/PasskeyRequestOptions", async (
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] UserManager<ApplicationUser> userManager,
|
||||||
|
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||||
|
[FromServices] IAntiforgery antiforgery,
|
||||||
|
[FromQuery] string? username) =>
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(context);
|
||||||
|
|
||||||
|
var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
|
||||||
|
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
|
||||||
|
return TypedResults.Content(optionsJson, contentType: "application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
|
||||||
|
|
||||||
|
manageGroup.MapPost("/LinkExternalLogin", async (
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||||
|
[FromForm] string provider) =>
|
||||||
|
{
|
||||||
|
// Clear the existing external cookie to ensure a clean login process
|
||||||
|
await context.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||||
|
|
||||||
|
var redirectUrl = UriHelper.BuildRelative(
|
||||||
|
context.Request.PathBase,
|
||||||
|
"/Account/Manage/ExternalLogins",
|
||||||
|
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
|
||||||
|
|
||||||
|
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
|
||||||
|
return TypedResults.Challenge(properties, [provider]);
|
||||||
|
});
|
||||||
|
|
||||||
|
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||||
|
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
|
||||||
|
|
||||||
|
manageGroup.MapPost("/DownloadPersonalData", async (
|
||||||
|
HttpContext context,
|
||||||
|
[FromServices] UserManager<ApplicationUser> userManager,
|
||||||
|
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
|
||||||
|
{
|
||||||
|
var user = await userManager.GetUserAsync(context.User);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = await userManager.GetUserIdAsync(user);
|
||||||
|
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
|
||||||
|
|
||||||
|
// Only include personal data for download
|
||||||
|
var personalData = new Dictionary<string, string>();
|
||||||
|
var personalDataProps = typeof(ApplicationUser).GetProperties().Where(
|
||||||
|
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
|
||||||
|
foreach (var p in personalDataProps)
|
||||||
|
{
|
||||||
|
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var logins = await userManager.GetLoginsAsync(user);
|
||||||
|
foreach (var l in logins)
|
||||||
|
{
|
||||||
|
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
|
||||||
|
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
|
||||||
|
|
||||||
|
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
|
||||||
|
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
return accountGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
|
||||||
|
namespace EntKube.Web.Components.Account;
|
||||||
|
|
||||||
|
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
|
||||||
|
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
|
||||||
|
{
|
||||||
|
private readonly IEmailSender emailSender = new NoOpEmailSender();
|
||||||
|
|
||||||
|
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
|
||||||
|
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
|
||||||
|
|
||||||
|
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
|
||||||
|
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
|
||||||
|
|
||||||
|
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
|
||||||
|
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
|
||||||
|
namespace EntKube.Web.Components.Account;
|
||||||
|
|
||||||
|
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
|
||||||
|
{
|
||||||
|
public const string StatusCookieName = "Identity.StatusMessage";
|
||||||
|
|
||||||
|
private static readonly CookieBuilder StatusCookieBuilder = new()
|
||||||
|
{
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
HttpOnly = true,
|
||||||
|
IsEssential = true,
|
||||||
|
MaxAge = TimeSpan.FromSeconds(5),
|
||||||
|
};
|
||||||
|
|
||||||
|
public void RedirectTo(string? uri)
|
||||||
|
{
|
||||||
|
uri ??= "";
|
||||||
|
|
||||||
|
// Prevent open redirects.
|
||||||
|
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
|
||||||
|
{
|
||||||
|
uri = navigationManager.ToBaseRelativePath(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigationManager.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
|
||||||
|
{
|
||||||
|
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
||||||
|
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
||||||
|
RedirectTo(newUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RedirectToWithStatus(string uri, string message, HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
|
||||||
|
RedirectTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
||||||
|
|
||||||
|
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
||||||
|
|
||||||
|
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
|
||||||
|
=> RedirectToWithStatus(CurrentPath, message, context);
|
||||||
|
|
||||||
|
public void RedirectToInvalidUser(UserManager<ApplicationUser> userManager, HttpContext context)
|
||||||
|
=> RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components.Server;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
|
||||||
|
namespace EntKube.Web.Components.Account;
|
||||||
|
|
||||||
|
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
||||||
|
// every 30 minutes an interactive circuit is connected.
|
||||||
|
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<IdentityOptions> options)
|
||||||
|
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||||
|
{
|
||||||
|
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||||
|
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Get the user manager from a new scope to ensure it fetches fresh data
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
var user = await userManager.GetUserAsync(principal);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if (!userManager.SupportsUserSecurityStamp)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
|
||||||
|
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||||
|
return principalStamp == userStamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@using System.Text
|
@using System.Text
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using EntKube.Data
|
@using EntKube.Web.Data
|
||||||
|
|
||||||
@inject UserManager<ApplicationUser> UserManager
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@inject IdentityRedirectManager RedirectManager
|
@inject IdentityRedirectManager RedirectManager
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@using System.Text
|
@using System.Text
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using EntKube.Data
|
@using EntKube.Web.Data
|
||||||
|
|
||||||
@inject UserManager<ApplicationUser> UserManager
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
@using System.Text.Encodings.Web
|
@using System.Text.Encodings.Web
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using EntKube.Data
|
@using EntKube.Web.Data
|
||||||
|
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@inject UserManager<ApplicationUser> UserManager
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
@using System.Text.Encodings.Web
|
@using System.Text.Encodings.Web
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using EntKube.Data
|
@using EntKube.Web.Data
|
||||||
|
|
||||||
@inject UserManager<ApplicationUser> UserManager
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@inject IEmailSender<ApplicationUser> EmailSender
|
@inject IEmailSender<ApplicationUser> EmailSender
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user