Add unit tests for KubernetesCluster, Tenant, ServiceInstance, and RegisterClusterHandler
Some checks failed
Build EntKube / build (push) Failing after 27s
Package Helm Chart / lint (push) Failing after 36s
Package Helm Chart / package (push) Has been skipped

- 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:
Nils Blomgren
2026-05-05 11:44:36 +02:00
parent 461fa36a46
commit a96dd33039
203 changed files with 2876 additions and 553 deletions

View 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

View 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

View File

@@ -400,60 +400,91 @@ If the service or project you are working on does not have a corresponding test
## Architecture ## Architecture
### Modular Monolith with Blazor ### Microservices with Blazor BFF
EntKube is a **modular monolith** — a single deployable Blazor application with clearly separated domain modules internally. This keeps deployment simple while maintaining clean boundaries between concerns. EntKube follows a **microservices architecture** with a Blazor BFF (Backend-for-Frontend) as the user-facing entry point. Each service owns its bounded context, has its own data store, and can be deployed and scaled independently. This is NOT a nano-services architecture — we split by meaningful business boundaries, not by technical layers.
#### Core Principles #### Core Principles
- **Domain modules within one application**: Each business capability (cluster management, service provisioning, tenant management, etc.) lives in its own namespace/folder but deploys as part of the single application - **4 services, each with a clear responsibility**: Web (BFF), Clusters, Provisioning, Identity
- **Clear module boundaries**: Modules communicate through well-defined interfaces — never reach directly into another module's internals - **Each service owns its data**: No shared databases between services
- **Shared database with schema separation**: The single application owns its database, but each module owns its tables/schema area - **Services communicate via HTTP APIs**: Simple REST calls between services, with resilient retry policies
- **Extract to a service only when necessary**: If a module genuinely needs independent scaling or a separate lifecycle, extract it then — not before - **SharedKernel for contracts only**: Shared types (Result, ApiResponse, base Entity) live in a shared library — but no shared business logic
- **Feature folders over layer folders**: Each feature is a vertical slice (handler + endpoint + related types in one folder)
#### When to Extract a Module to a Separate Service #### Service Boundaries
- The module has drastically different scaling requirements
- The module needs to be deployed on a different cadence | Service | Responsibility | Port (dev) |
- The module introduces an external integration that benefits from fault isolation |---------|---------------|-------------|
| **EntKube.Web** | Blazor BFF — serves UI, proxies API calls to backend services, owns user auth session | 5000 |
| **EntKube.Clusters** | Kubernetes cluster registration, health monitoring, API connectivity | 5010 |
| **EntKube.Provisioning** | Shared service lifecycle (MinIO, CNPG, Keycloak) — provisioning, reconciliation, teardown | 5020 |
| **EntKube.Identity** | Tenant management, user membership, roles, Keycloak integration | 5030 |
#### Anti-Patterns to Avoid #### Anti-Patterns to Avoid
``` ```
# ❌ BAD: Premature microservices for a platform that deploys as one unit # ❌ BAD: Nano-services — splitting too granularly
Services/ MinIOService/
├── ClusterService/ CloudNativePGService/
├── TenantService/ KeycloakService/
├── MonitoringService/ HealthCheckService/ # These belong together under "Provisioning"
└── ProvisioningService/ # All deployed together anyway
# ✅ GOOD: Modules within the monolith with clear boundaries # ❌ BAD: Shared database between services
EntKube/ # Services must own their own data — cross-service queries go through APIs
├── Clusters/ # Cluster management module
├── Tenants/ # Multi-tenant module # ✅ GOOD: Meaningful service boundaries with feature folders
├── Monitoring/ # Observability module src/
├── Provisioning/ # Service provisioning module ├── EntKube.Web/ # Blazor BFF
── Shared/ # Cross-cutting concerns ── EntKube.Clusters/ # Cluster management service
│ ├── Domain/ # Aggregates, value objects, repository contracts
│ ├── Features/ # Vertical slices (RegisterCluster/, GetClusters/, etc.)
│ └── Infrastructure/ # Repository implementations, external integrations
├── EntKube.Provisioning/ # Service provisioning service
│ ├── Domain/
│ ├── Features/
│ └── Infrastructure/
├── EntKube.Identity/ # Identity & tenant service
│ ├── Domain/
│ ├── Features/
│ └── Infrastructure/
└── EntKube.SharedKernel/ # Shared contracts (Result, ApiResponse, base Entity)
``` ```
### Project Structure ### Project Structure
``` ```
Solution/ Solution/
├── EntKube/ # Blazor Server host (BFF) ├── src/
│ ├── Components/ # Razor components, layouts, pages │ ├── EntKube.SharedKernel/ # Shared types and contracts between services
│ ├── Data/ # EF Core DbContext and migrations ├── Domain/ # Result, Entity base class
├── Clusters/ # Kubernetes cluster management │ └── Contracts/ # ApiResponse envelope, DTOs
│ ├── Provisioning/ # Shared service provisioning (MinIO, CNPG, Keycloak) │ ├── EntKube.Web/ # Blazor Server BFF
├── Tenants/ # Multi-tenant configuration │ ├── Components/ # Razor components, layouts, pages
├── Monitoring/ # Health, metrics, observability │ ├── Data/ # EF Core DbContext (Identity only)
└── Pipelines/ # CI/CD pipeline integration │ └── wwwroot/ # Static assets
├── EntKube.Client/ # Blazor WebAssembly client ├── EntKube.Web.Client/ # Blazor WebAssembly client
── Pages/ # Interactive WASM pages │ └── Pages/ # Interactive WASM pages
── wwwroot/ # Client static assets ── EntKube.Clusters/ # Cluster management API
│ │ ├── 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 ├── Charts/ # Helm charts for deployment
│ └── entkube/ │ └── entkube/
│ ├── Chart.yaml │ ├── Chart.yaml
│ ├── values.yaml │ ├── values.yaml
│ └── templates/ │ └── templates/
└── Tests/ └── .gitea/workflows/ # Gitea Actions CI/CD
└── *.Tests/ # xUnit test projects
``` ```
### Deployment & Infrastructure ### Deployment & Infrastructure
@@ -511,10 +542,11 @@ jobs:
``` ```
### Communication Patterns ### Communication Patterns
- Internal module communication via dependency injection and in-process calls - **Service-to-service**: HTTP REST via typed HttpClient with Polly retry policies
- Kubernetes API communication for cluster management operations - **BFF-to-service**: The Web BFF proxies user requests to the appropriate backend service
- HTTP APIs exposed for external integrations - **Kubernetes API**: The Clusters service communicates with k8s API servers using the official .NET client
- Use resilient connections with retry policies (Polly) for external calls - **Async workflows**: Background services within each microservice handle reconciliation loops (e.g., provisioning, health checks)
- **No message bus yet**: Start with synchronous HTTP; extract to async messaging (NATS, RabbitMQ) only when proven necessary
### Database ### Database

View File

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

13
Charts/entkube/Chart.yaml Normal file
View 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

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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: {}

View File

@@ -1,4 +1,14 @@
<Solution> <Solution>
<Project Path="EntKube/EntKube.Client/EntKube.Client.csproj" Id="73e36587-8940-4269-adcc-bab1e068ed1e" /> <!-- Source Projects -->
<Project Path="EntKube/EntKube/EntKube.csproj" /> <Project Path="src/EntKube.SharedKernel/EntKube.SharedKernel.csproj" />
<Project Path="src/EntKube.Identity/EntKube.Identity.csproj" />
<Project Path="src/EntKube.Clusters/EntKube.Clusters.csproj" />
<Project Path="src/EntKube.Provisioning/EntKube.Provisioning.csproj" />
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
<!-- Test Projects -->
<Project Path="tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj" />
<Project Path="tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj" />
<Project Path="tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj" />
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
</Solution> </Solution>

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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}");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -1,2 +0,0 @@
@using EntKube.Components.Account.Shared
@attribute [ExcludeFromInteractiveRouting]

View File

@@ -1,8 +0,0 @@
namespace EntKube.Components.Account
{
public class PasskeyInputModel
{
public string? CredentialJson { get; set; }
public string? Error { get; set; }
}
}

View File

@@ -1,8 +0,0 @@
namespace EntKube.Components.Account
{
public enum PasskeyOperation
{
Create = 0,
Request = 1,
}
}

View File

@@ -1,9 +0,0 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Data
{
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
{
}
}

View File

@@ -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
{
}
}

View File

@@ -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();
}
}
}

View File

@@ -1,8 +0,0 @@
{
"dependencies": {
"mssql1": {
"type": "mssql",
"connectionId": "ConnectionStrings:DefaultConnection"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"dependencies": {
"mssql1": {
"type": "mssql.local",
"connectionId": "ConnectionStrings:DefaultConnection"
}
}
}

View 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);
}

View 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
}

View 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>

View File

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

View File

@@ -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);

View File

@@ -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!));
});
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View 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();
}
}

View 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"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View 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);
}

View 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
}

View 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>

View File

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

View File

@@ -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!));
});
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View 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();
}
}

View 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"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -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);
}

View 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
}

View 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>

View File

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

View File

@@ -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);

View File

@@ -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!));
});
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View 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();
}
}

View 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"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View 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
};
}

View 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();
}

View 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;
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -10,8 +10,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -4,7 +4,7 @@
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="">EntKube</a> <a class="navbar-brand" href="">EntKube.Web</a>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script> <script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet> <dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container"> <div class="components-reconnect-container">
@@ -21,11 +21,11 @@
<p class="components-pause-visible"> <p class="components-pause-visible">
The session has been paused by the server. The session has been paused by the server.
</p> </p>
<p class="components-resume-failed-visible"> <button id="components-resume-button" class="components-pause-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume Resume
</button> </button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please reload the page.
</p>
</div> </div>
</dialog> </dialog>

View File

@@ -52,7 +52,7 @@ async function resume() {
location.reload(); location.reload();
} }
} catch { } catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); location.reload();
} }
} }

View File

@@ -3,7 +3,6 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@attribute [Authorize] @attribute [Authorize]
@rendermode InteractiveAuto
<PageTitle>Auth</PageTitle> <PageTitle>Auth</PageTitle>

View File

@@ -1,5 +1,4 @@
@page "/counter" @page "/counter"
@rendermode InteractiveAuto
<PageTitle>Counter</PageTitle> <PageTitle>Counter</PageTitle>

View File

@@ -1,5 +1,4 @@
@page "/weather" @page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle> <PageTitle>Weather</PageTitle>
@@ -41,7 +40,7 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Simulate asynchronous loading to demonstrate streaming rendering // Simulate asynchronous loading to demonstrate a loading indicator
await Task.Delay(500); await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now); var startDate = DateOnly.FromDateTime(DateTime.Now);

View 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();

View File

@@ -1,4 +1,4 @@
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }" NotFoundPage="typeof(Pages.NotFound)"> <Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"> <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>

View File

@@ -7,4 +7,5 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using EntKube.Client @using EntKube.Web.Client
@using EntKube.Web.Client.Layout

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -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;
}
}

View File

@@ -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}");
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -3,7 +3,7 @@
@using System.Text @using System.Text
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using EntKube.Data @using EntKube.Web.Data
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager
@inject IdentityRedirectManager RedirectManager @inject IdentityRedirectManager RedirectManager

View File

@@ -3,7 +3,7 @@
@using System.Text @using System.Text
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using EntKube.Data @using EntKube.Web.Data
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager

View File

@@ -6,7 +6,7 @@
@using System.Text.Encodings.Web @using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using EntKube.Data @using EntKube.Web.Data
@inject SignInManager<ApplicationUser> SignInManager @inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager

View File

@@ -5,7 +5,7 @@
@using System.Text.Encodings.Web @using System.Text.Encodings.Web
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using EntKube.Data @using EntKube.Web.Data
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager
@inject IEmailSender<ApplicationUser> EmailSender @inject IEmailSender<ApplicationUser> EmailSender

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