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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
- **Clear module boundaries**: Modules communicate through well-defined interfaces — never reach directly into another module's internals
|
||||
- **Shared database with schema separation**: The single application owns its database, but each module owns its tables/schema area
|
||||
- **Extract to a service only when necessary**: If a module genuinely needs independent scaling or a separate lifecycle, extract it then — not before
|
||||
- **4 services, each with a clear responsibility**: Web (BFF), Clusters, Provisioning, Identity
|
||||
- **Each service owns its data**: No shared databases between services
|
||||
- **Services communicate via HTTP APIs**: Simple REST calls between services, with resilient retry policies
|
||||
- **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
|
||||
- The module has drastically different scaling requirements
|
||||
- The module needs to be deployed on a different cadence
|
||||
- The module introduces an external integration that benefits from fault isolation
|
||||
#### Service Boundaries
|
||||
|
||||
| Service | Responsibility | Port (dev) |
|
||||
|---------|---------------|-------------|
|
||||
| **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
|
||||
```
|
||||
# ❌ BAD: Premature microservices for a platform that deploys as one unit
|
||||
Services/
|
||||
├── ClusterService/
|
||||
├── TenantService/
|
||||
├── MonitoringService/
|
||||
└── ProvisioningService/ # All deployed together anyway
|
||||
# ❌ BAD: Nano-services — splitting too granularly
|
||||
MinIOService/
|
||||
CloudNativePGService/
|
||||
KeycloakService/
|
||||
HealthCheckService/ # These belong together under "Provisioning"
|
||||
|
||||
# ✅ GOOD: Modules within the monolith with clear boundaries
|
||||
EntKube/
|
||||
├── Clusters/ # Cluster management module
|
||||
├── Tenants/ # Multi-tenant module
|
||||
├── Monitoring/ # Observability module
|
||||
├── Provisioning/ # Service provisioning module
|
||||
└── Shared/ # Cross-cutting concerns
|
||||
# ❌ BAD: Shared database between services
|
||||
# Services must own their own data — cross-service queries go through APIs
|
||||
|
||||
# ✅ GOOD: Meaningful service boundaries with feature folders
|
||||
src/
|
||||
├── EntKube.Web/ # Blazor BFF
|
||||
├── 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
|
||||
```
|
||||
Solution/
|
||||
├── EntKube/ # Blazor Server host (BFF)
|
||||
│ ├── Components/ # Razor components, layouts, pages
|
||||
│ ├── Data/ # EF Core DbContext and migrations
|
||||
│ ├── Clusters/ # Kubernetes cluster management
|
||||
│ ├── Provisioning/ # Shared service provisioning (MinIO, CNPG, Keycloak)
|
||||
│ ├── Tenants/ # Multi-tenant configuration
|
||||
│ ├── Monitoring/ # Health, metrics, observability
|
||||
│ └── Pipelines/ # CI/CD pipeline integration
|
||||
├── EntKube.Client/ # Blazor WebAssembly client
|
||||
│ ├── Pages/ # Interactive WASM pages
|
||||
│ └── wwwroot/ # Client static assets
|
||||
├── Charts/ # Helm charts for deployment
|
||||
├── src/
|
||||
│ ├── EntKube.SharedKernel/ # Shared types and contracts between services
|
||||
│ │ ├── Domain/ # Result, Entity base class
|
||||
│ │ └── Contracts/ # ApiResponse envelope, DTOs
|
||||
│ ├── EntKube.Web/ # Blazor Server BFF
|
||||
│ │ ├── Components/ # Razor components, layouts, pages
|
||||
│ │ ├── Data/ # EF Core DbContext (Identity only)
|
||||
│ │ └── wwwroot/ # Static assets
|
||||
│ ├── EntKube.Web.Client/ # Blazor WebAssembly client
|
||||
│ │ └── Pages/ # Interactive WASM pages
|
||||
│ ├── EntKube.Clusters/ # Cluster management API
|
||||
│ │ ├── 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/
|
||||
│ ├── Chart.yaml
|
||||
│ ├── values.yaml
|
||||
│ └── templates/
|
||||
└── Tests/
|
||||
└── *.Tests/ # xUnit test projects
|
||||
└── .gitea/workflows/ # Gitea Actions CI/CD
|
||||
```
|
||||
|
||||
### Deployment & Infrastructure
|
||||
@@ -511,10 +542,11 @@ jobs:
|
||||
```
|
||||
|
||||
### Communication Patterns
|
||||
- Internal module communication via dependency injection and in-process calls
|
||||
- Kubernetes API communication for cluster management operations
|
||||
- HTTP APIs exposed for external integrations
|
||||
- Use resilient connections with retry policies (Polly) for external calls
|
||||
- **Service-to-service**: HTTP REST via typed HttpClient with Polly retry policies
|
||||
- **BFF-to-service**: The Web BFF proxies user requests to the appropriate backend service
|
||||
- **Kubernetes API**: The Clusters service communicates with k8s API servers using the official .NET client
|
||||
- **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
|
||||
|
||||
|
||||
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>
|
||||
<Project Path="EntKube/EntKube.Client/EntKube.Client.csproj" Id="73e36587-8940-4269-adcc-bab1e068ed1e" />
|
||||
<Project Path="EntKube/EntKube/EntKube.csproj" />
|
||||
<!-- Source Projects -->
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">EntKube</a>
|
||||
<a class="navbar-brand" href="">EntKube.Web</a>
|
||||
</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>
|
||||
<div class="components-reconnect-container">
|
||||
@@ -21,11 +21,11 @@
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-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">
|
||||
<button id="components-resume-button" class="components-pause-visible">
|
||||
Resume
|
||||
</button>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please reload the page.
|
||||
</p>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -52,7 +52,7 @@ async function resume() {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveAuto
|
||||
|
||||
<PageTitle>Auth</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveAuto
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/weather"
|
||||
@attribute [StreamRendering]
|
||||
|
||||
<PageTitle>Weather</PageTitle>
|
||||
|
||||
@@ -41,7 +40,7 @@ else
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
||||
// Simulate asynchronous loading to demonstrate a loading indicator
|
||||
await Task.Delay(500);
|
||||
|
||||
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">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
@@ -7,4 +7,5 @@
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@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 Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using EntKube.Data
|
||||
@using EntKube.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@@ -3,7 +3,7 @@
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using EntKube.Data
|
||||
@using EntKube.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@@ -6,7 +6,7 @@
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using EntKube.Data
|
||||
@using EntKube.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@@ -5,7 +5,7 @@
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using EntKube.Data
|
||||
@using EntKube.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@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