diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..ee7c601 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitea/workflows/helm.yaml b/.gitea/workflows/helm.yaml new file mode 100644 index 0000000..23ab44e --- /dev/null +++ b/.gitea/workflows/helm.yaml @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f198b02..b4b4a02 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 diff --git a/Charts/entkube/.helmignore b/Charts/entkube/.helmignore new file mode 100644 index 0000000..d3ce842 --- /dev/null +++ b/Charts/entkube/.helmignore @@ -0,0 +1,4 @@ +# Patterns to ignore when packaging the chart +.DS_Store +*.tgz +.git/ diff --git a/Charts/entkube/Chart.yaml b/Charts/entkube/Chart.yaml new file mode 100644 index 0000000..a6a531b --- /dev/null +++ b/Charts/entkube/Chart.yaml @@ -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 diff --git a/Charts/entkube/templates/_helpers.tpl b/Charts/entkube/templates/_helpers.tpl new file mode 100644 index 0000000..af2bef4 --- /dev/null +++ b/Charts/entkube/templates/_helpers.tpl @@ -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 }} diff --git a/Charts/entkube/templates/clusters-deployment.yaml b/Charts/entkube/templates/clusters-deployment.yaml new file mode 100644 index 0000000..6eee296 --- /dev/null +++ b/Charts/entkube/templates/clusters-deployment.yaml @@ -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 }} diff --git a/Charts/entkube/templates/clusters-service.yaml b/Charts/entkube/templates/clusters-service.yaml new file mode 100644 index 0000000..aa3a6d6 --- /dev/null +++ b/Charts/entkube/templates/clusters-service.yaml @@ -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 }} diff --git a/Charts/entkube/templates/identity-deployment.yaml b/Charts/entkube/templates/identity-deployment.yaml new file mode 100644 index 0000000..fed23ab --- /dev/null +++ b/Charts/entkube/templates/identity-deployment.yaml @@ -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 }} diff --git a/Charts/entkube/templates/identity-service.yaml b/Charts/entkube/templates/identity-service.yaml new file mode 100644 index 0000000..7899e4c --- /dev/null +++ b/Charts/entkube/templates/identity-service.yaml @@ -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 }} diff --git a/Charts/entkube/templates/ingress.yaml b/Charts/entkube/templates/ingress.yaml new file mode 100644 index 0000000..b096479 --- /dev/null +++ b/Charts/entkube/templates/ingress.yaml @@ -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 }} diff --git a/Charts/entkube/templates/provisioning-deployment.yaml b/Charts/entkube/templates/provisioning-deployment.yaml new file mode 100644 index 0000000..5ea565c --- /dev/null +++ b/Charts/entkube/templates/provisioning-deployment.yaml @@ -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 }} diff --git a/Charts/entkube/templates/provisioning-service.yaml b/Charts/entkube/templates/provisioning-service.yaml new file mode 100644 index 0000000..c5a8903 --- /dev/null +++ b/Charts/entkube/templates/provisioning-service.yaml @@ -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 }} diff --git a/Charts/entkube/templates/serviceaccount.yaml b/Charts/entkube/templates/serviceaccount.yaml new file mode 100644 index 0000000..7d4da49 --- /dev/null +++ b/Charts/entkube/templates/serviceaccount.yaml @@ -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 }} diff --git a/Charts/entkube/templates/web-deployment.yaml b/Charts/entkube/templates/web-deployment.yaml new file mode 100644 index 0000000..23cc2b2 --- /dev/null +++ b/Charts/entkube/templates/web-deployment.yaml @@ -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 }} diff --git a/Charts/entkube/templates/web-service.yaml b/Charts/entkube/templates/web-service.yaml new file mode 100644 index 0000000..b96ac7e --- /dev/null +++ b/Charts/entkube/templates/web-service.yaml @@ -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 }} diff --git a/Charts/entkube/values.yaml b/Charts/entkube/values.yaml new file mode 100644 index 0000000..bd0cc41 --- /dev/null +++ b/Charts/entkube/values.yaml @@ -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: {} diff --git a/EntKube.slnx b/EntKube.slnx index 8d1316b..56a677c 100644 --- a/EntKube.slnx +++ b/EntKube.slnx @@ -1,4 +1,14 @@ - - + + + + + + + + + + + + diff --git a/EntKube/EntKube.Client/Program.cs b/EntKube/EntKube.Client/Program.cs deleted file mode 100644 index 071fc43..0000000 --- a/EntKube/EntKube.Client/Program.cs +++ /dev/null @@ -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(); - } - } -} diff --git a/EntKube/EntKube/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/EntKube/EntKube/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs deleted file mode 100644 index 5673415..0000000 --- a/EntKube/EntKube/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs +++ /dev/null @@ -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 signInManager, - [FromForm] string provider, - [FromForm] string returnUrl) => - { - IEnumerable> 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 signInManager, - [FromForm] string returnUrl) => - { - await signInManager.SignOutAsync(); - return TypedResults.LocalRedirect($"~/{returnUrl}"); - }); - - accountGroup.MapPost("/PasskeyCreationOptions", async ( - HttpContext context, - [FromServices] UserManager userManager, - [FromServices] SignInManager 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 userManager, - [FromServices] SignInManager 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 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(); - var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); - - manageGroup.MapPost("/DownloadPersonalData", async ( - HttpContext context, - [FromServices] UserManager 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(); - 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; - } - } -} diff --git a/EntKube/EntKube/Components/Account/IdentityNoOpEmailSender.cs b/EntKube/EntKube/Components/Account/IdentityNoOpEmailSender.cs deleted file mode 100644 index 651c27b..0000000 --- a/EntKube/EntKube/Components/Account/IdentityNoOpEmailSender.cs +++ /dev/null @@ -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 - { - 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 clicking here."); - - public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => - emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); - - public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => - emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); - } -} diff --git a/EntKube/EntKube/Components/Account/IdentityRedirectManager.cs b/EntKube/EntKube/Components/Account/IdentityRedirectManager.cs deleted file mode 100644 index 72fdd07..0000000 --- a/EntKube/EntKube/Components/Account/IdentityRedirectManager.cs +++ /dev/null @@ -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 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 userManager, HttpContext context) - => RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); - } -} diff --git a/EntKube/EntKube/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs b/EntKube/EntKube/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs deleted file mode 100644 index 0754ac4..0000000 --- a/EntKube/EntKube/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs +++ /dev/null @@ -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 options) - : RevalidatingServerAuthenticationStateProvider(loggerFactory) - { - protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); - - protected override async Task 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>(); - return await ValidateSecurityStampAsync(userManager, authenticationState.User); - } - - private async Task ValidateSecurityStampAsync(UserManager 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; - } - } - } -} diff --git a/EntKube/EntKube/Components/Account/Pages/_Imports.razor b/EntKube/EntKube/Components/Account/Pages/_Imports.razor deleted file mode 100644 index 9ff7729..0000000 --- a/EntKube/EntKube/Components/Account/Pages/_Imports.razor +++ /dev/null @@ -1,2 +0,0 @@ -@using EntKube.Components.Account.Shared -@attribute [ExcludeFromInteractiveRouting] diff --git a/EntKube/EntKube/Components/Account/PasskeyInputModel.cs b/EntKube/EntKube/Components/Account/PasskeyInputModel.cs deleted file mode 100644 index 35caaae..0000000 --- a/EntKube/EntKube/Components/Account/PasskeyInputModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace EntKube.Components.Account -{ - public class PasskeyInputModel - { - public string? CredentialJson { get; set; } - public string? Error { get; set; } - } -} diff --git a/EntKube/EntKube/Components/Account/PasskeyOperation.cs b/EntKube/EntKube/Components/Account/PasskeyOperation.cs deleted file mode 100644 index 3e08b99..0000000 --- a/EntKube/EntKube/Components/Account/PasskeyOperation.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace EntKube.Components.Account -{ - public enum PasskeyOperation - { - Create = 0, - Request = 1, - } -} diff --git a/EntKube/EntKube/Data/ApplicationDbContext.cs b/EntKube/EntKube/Data/ApplicationDbContext.cs deleted file mode 100644 index 45f4d42..0000000 --- a/EntKube/EntKube/Data/ApplicationDbContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; - -namespace EntKube.Data -{ - public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) - { - } -} diff --git a/EntKube/EntKube/Data/ApplicationUser.cs b/EntKube/EntKube/Data/ApplicationUser.cs deleted file mode 100644 index b952a3d..0000000 --- a/EntKube/EntKube/Data/ApplicationUser.cs +++ /dev/null @@ -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 - { - } - -} diff --git a/EntKube/EntKube/Program.cs b/EntKube/EntKube/Program.cs deleted file mode 100644 index 564c26d..0000000 --- a/EntKube/EntKube/Program.cs +++ /dev/null @@ -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(); - builder.Services.AddScoped(); - - 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(options => - options.UseSqlite(connectionString)); - builder.Services.AddDatabaseDeveloperPageExceptionFilter(); - - builder.Services.AddIdentityCore(options => - { - options.SignIn.RequireConfirmedAccount = true; - options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; - }) - .AddEntityFrameworkStores() - .AddSignInManager() - .AddDefaultTokenProviders(); - - builder.Services.AddSingleton, 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() - .AddInteractiveServerRenderMode() - .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(Client._Imports).Assembly); - - // Add additional endpoints required by the Identity /Account Razor components. - app.MapAdditionalIdentityEndpoints(); - - app.Run(); - } - } -} diff --git a/EntKube/EntKube/Properties/serviceDependencies.json b/EntKube/EntKube/Properties/serviceDependencies.json deleted file mode 100644 index cf3233b..0000000 --- a/EntKube/EntKube/Properties/serviceDependencies.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "dependencies": { - "mssql1": { - "type": "mssql", - "connectionId": "ConnectionStrings:DefaultConnection" - } - } -} \ No newline at end of file diff --git a/EntKube/EntKube/Properties/serviceDependencies.local.json b/EntKube/EntKube/Properties/serviceDependencies.local.json deleted file mode 100644 index e2fc824..0000000 --- a/EntKube/EntKube/Properties/serviceDependencies.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "dependencies": { - "mssql1": { - "type": "mssql.local", - "connectionId": "ConnectionStrings:DefaultConnection" - } - } -} \ No newline at end of file diff --git a/src/EntKube.Clusters/Domain/IClusterRepository.cs b/src/EntKube.Clusters/Domain/IClusterRepository.cs new file mode 100644 index 0000000..c5a5501 --- /dev/null +++ b/src/EntKube.Clusters/Domain/IClusterRepository.cs @@ -0,0 +1,15 @@ +namespace EntKube.Clusters.Domain; + +/// +/// Defines how the cluster service persists and retrieves cluster aggregates. +/// The implementation lives in the infrastructure layer — the domain only +/// knows about this contract, keeping it persistence-agnostic. +/// +public interface IClusterRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default); + Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); +} diff --git a/src/EntKube.Clusters/Domain/KubernetesCluster.cs b/src/EntKube.Clusters/Domain/KubernetesCluster.cs new file mode 100644 index 0000000..e5a89b7 --- /dev/null +++ b/src/EntKube.Clusters/Domain/KubernetesCluster.cs @@ -0,0 +1,76 @@ +namespace EntKube.Clusters.Domain; + +/// +/// A KubernetesCluster represents a registered cluster that EntKube manages. +/// It holds the connection details, health state, and metadata needed to +/// interact with the cluster's API server. This is the aggregate root for +/// all cluster-related operations. +/// +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() { } + + /// + /// 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. + /// + 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 + }; + } + + /// + /// After a successful health check, we mark the cluster as connected. + /// This means the platform can now schedule work against this cluster. + /// + public void MarkConnected() + { + Status = ClusterStatus.Connected; + LastHealthCheckAt = DateTimeOffset.UtcNow; + } + + /// + /// When a health check fails, we mark the cluster as unreachable. + /// Existing workloads keep running, but no new provisioning can happen + /// until connectivity is restored. + /// + public void MarkUnreachable() + { + Status = ClusterStatus.Unreachable; + LastHealthCheckAt = DateTimeOffset.UtcNow; + } +} + +public enum ClusterStatus +{ + Pending, + Connected, + Unreachable +} diff --git a/src/EntKube.Clusters/EntKube.Clusters.csproj b/src/EntKube.Clusters/EntKube.Clusters.csproj new file mode 100644 index 0000000..86d7ffb --- /dev/null +++ b/src/EntKube.Clusters/EntKube.Clusters.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/EntKube.Clusters/EntKube.Clusters.http b/src/EntKube.Clusters/EntKube.Clusters.http new file mode 100644 index 0000000..b431c29 --- /dev/null +++ b/src/EntKube.Clusters/EntKube.Clusters.http @@ -0,0 +1,6 @@ +@EntKube.Clusters_HostAddress = http://localhost:5243 + +GET {{EntKube.Clusters_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/EntKube.Clusters/Features/GetClusters/GetClustersEndpoint.cs b/src/EntKube.Clusters/Features/GetClusters/GetClustersEndpoint.cs new file mode 100644 index 0000000..9aa1f78 --- /dev/null +++ b/src/EntKube.Clusters/Features/GetClusters/GetClustersEndpoint.cs @@ -0,0 +1,39 @@ +using EntKube.Clusters.Domain; +using EntKube.SharedKernel.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace EntKube.Clusters.Features.GetClusters; + +/// +/// Lists all registered clusters. The BFF (Web service) calls this endpoint +/// to populate the clusters dashboard. Returns a lightweight summary of each +/// cluster's name, status, and last health check time. +/// +public static class GetClustersEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("/api/clusters", async ( + [FromServices] IClusterRepository repository, + CancellationToken ct) => + { + IReadOnlyList clusters = await repository.GetAllAsync(ct); + + List summaries = clusters.Select(c => new ClusterSummary( + c.Id, + c.Name, + c.ApiServerUrl, + c.Status.ToString(), + c.LastHealthCheckAt)).ToList(); + + return Results.Ok(ApiResponse>.Ok(summaries)); + }); + } +} + +public record ClusterSummary( + Guid Id, + string Name, + string ApiServerUrl, + string Status, + DateTimeOffset? LastHealthCheckAt); diff --git a/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterEndpoint.cs b/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterEndpoint.cs new file mode 100644 index 0000000..2905718 --- /dev/null +++ b/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterEndpoint.cs @@ -0,0 +1,30 @@ +using EntKube.Clusters.Domain; +using EntKube.SharedKernel.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace EntKube.Clusters.Features.RegisterCluster; + +/// +/// Maps the HTTP POST /api/clusters endpoint. Receives a registration request, +/// delegates to the handler, and returns the new cluster's ID on success. +/// +public static class RegisterClusterEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("/api/clusters", async ( + [FromBody] RegisterClusterRequest request, + [FromServices] RegisterClusterHandler handler, + CancellationToken ct) => + { + EntKube.SharedKernel.Domain.Result result = await handler.HandleAsync(request, ct); + + if (result.IsFailure) + { + return Results.BadRequest(ApiResponse.Fail(result.Error!)); + } + + return Results.Created($"/api/clusters/{result.Value}", ApiResponse.Ok(result.Value!)); + }); + } +} diff --git a/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterHandler.cs b/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterHandler.cs new file mode 100644 index 0000000..e6348be --- /dev/null +++ b/src/EntKube.Clusters/Features/RegisterCluster/RegisterClusterHandler.cs @@ -0,0 +1,53 @@ +using EntKube.Clusters.Domain; +using EntKube.SharedKernel.Domain; + +namespace EntKube.Clusters.Features.RegisterCluster; + +/// +/// Handles the registration of a new Kubernetes cluster into the platform. +/// A tenant admin provides the cluster name, API server URL, and 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. +/// +public class RegisterClusterHandler +{ + private readonly IClusterRepository repository; + + public RegisterClusterHandler(IClusterRepository repository) + { + this.repository = repository; + } + + public async Task> HandleAsync(RegisterClusterRequest request, CancellationToken ct = default) + { + // Validate that the caller provided the minimum required information. + // Without a name and API URL, we cannot register a cluster. + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Result.Failure("Cluster name is required."); + } + + if (string.IsNullOrWhiteSpace(request.ApiServerUrl)) + { + return Result.Failure("API server URL is required."); + } + + // 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); diff --git a/src/EntKube.Clusters/Infrastructure/InMemoryClusterRepository.cs b/src/EntKube.Clusters/Infrastructure/InMemoryClusterRepository.cs new file mode 100644 index 0000000..a8c699a --- /dev/null +++ b/src/EntKube.Clusters/Infrastructure/InMemoryClusterRepository.cs @@ -0,0 +1,43 @@ +using EntKube.Clusters.Domain; + +namespace EntKube.Clusters.Infrastructure; + +/// +/// In-memory implementation of the cluster repository for local development +/// and testing. Production will replace this with an EF Core or Dapper +/// implementation backed by PostgreSQL. +/// +public class InMemoryClusterRepository : IClusterRepository +{ + private readonly List clusters = new(); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + KubernetesCluster? cluster = clusters.FirstOrDefault(c => c.Id == id); + return Task.FromResult(cluster); + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + IReadOnlyList result = clusters.AsReadOnly(); + return Task.FromResult(result); + } + + public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default) + { + clusters.Add(cluster); + return Task.CompletedTask; + } + + public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default) + { + // In-memory: the reference is already updated since we store the object directly. + return Task.CompletedTask; + } + + public Task DeleteAsync(Guid id, CancellationToken ct = default) + { + clusters.RemoveAll(c => c.Id == id); + return Task.CompletedTask; + } +} diff --git a/src/EntKube.Clusters/Program.cs b/src/EntKube.Clusters/Program.cs new file mode 100644 index 0000000..101d023 --- /dev/null +++ b/src/EntKube.Clusters/Program.cs @@ -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(); + builder.Services.AddScoped(); + 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(); + } +} diff --git a/src/EntKube.Clusters/Properties/launchSettings.json b/src/EntKube.Clusters/Properties/launchSettings.json new file mode 100644 index 0000000..6629696 --- /dev/null +++ b/src/EntKube.Clusters/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/EntKube/EntKube.Client/wwwroot/appsettings.Development.json b/src/EntKube.Clusters/appsettings.Development.json similarity index 100% rename from EntKube/EntKube.Client/wwwroot/appsettings.Development.json rename to src/EntKube.Clusters/appsettings.Development.json diff --git a/src/EntKube.Clusters/appsettings.json b/src/EntKube.Clusters/appsettings.json new file mode 100644 index 0000000..d0111f5 --- /dev/null +++ b/src/EntKube.Clusters/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/EntKube.Identity/Domain/ITenantRepository.cs b/src/EntKube.Identity/Domain/ITenantRepository.cs new file mode 100644 index 0000000..1a65cab --- /dev/null +++ b/src/EntKube.Identity/Domain/ITenantRepository.cs @@ -0,0 +1,13 @@ +namespace EntKube.Identity.Domain; + +/// +/// Defines how the identity service persists and retrieves tenants. +/// +public interface ITenantRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task GetBySlugAsync(string slug, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task AddAsync(Tenant tenant, CancellationToken ct = default); + Task UpdateAsync(Tenant tenant, CancellationToken ct = default); +} diff --git a/src/EntKube.Identity/Domain/Tenant.cs b/src/EntKube.Identity/Domain/Tenant.cs new file mode 100644 index 0000000..26c5476 --- /dev/null +++ b/src/EntKube.Identity/Domain/Tenant.cs @@ -0,0 +1,91 @@ +namespace EntKube.Identity.Domain; + +/// +/// A Tenant represents an organization or team using the EntKube platform. +/// Each tenant has isolated access to clusters and provisioned services. +/// The Identity service owns tenant lifecycle (creation, suspension, deletion) +/// and user membership within tenants. +/// +public class Tenant +{ + public Guid Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public string Slug { get; private set; } = string.Empty; + public TenantStatus Status { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private readonly List members = new(); + public IReadOnlyList Members => members.AsReadOnly(); + + private Tenant() { } + + /// + /// Creates a new tenant in the platform. The creating user automatically + /// becomes the tenant's first admin member. + /// + public static Tenant Create(string name, string slug, Guid creatingUserId) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Tenant name is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(slug)) + { + throw new ArgumentException("Tenant slug is required.", nameof(slug)); + } + + Tenant tenant = new() + { + Id = Guid.NewGuid(), + Name = name, + Slug = slug.ToLowerInvariant(), + Status = TenantStatus.Active, + CreatedAt = DateTimeOffset.UtcNow + }; + + // The creator is automatically the admin of their new tenant. + + tenant.members.Add(new TenantMember(creatingUserId, TenantRole.Admin)); + + return tenant; + } + + /// + /// Adds a user to this tenant with the specified role. + /// + public void AddMember(Guid userId, TenantRole role) + { + if (members.Any(m => m.UserId == userId)) + { + return; + } + + members.Add(new TenantMember(userId, role)); + } + + public void Suspend() + { + Status = TenantStatus.Suspended; + } + + public void Activate() + { + Status = TenantStatus.Active; + } +} + +public record TenantMember(Guid UserId, TenantRole Role); + +public enum TenantStatus +{ + Active, + Suspended +} + +public enum TenantRole +{ + Admin, + Member, + Viewer +} diff --git a/src/EntKube.Identity/EntKube.Identity.csproj b/src/EntKube.Identity/EntKube.Identity.csproj new file mode 100644 index 0000000..86d7ffb --- /dev/null +++ b/src/EntKube.Identity/EntKube.Identity.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/EntKube.Identity/EntKube.Identity.http b/src/EntKube.Identity/EntKube.Identity.http new file mode 100644 index 0000000..796a773 --- /dev/null +++ b/src/EntKube.Identity/EntKube.Identity.http @@ -0,0 +1,6 @@ +@EntKube.Identity_HostAddress = http://localhost:5076 + +GET {{EntKube.Identity_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/EntKube.Identity/Features/CreateTenant/CreateTenantEndpoint.cs b/src/EntKube.Identity/Features/CreateTenant/CreateTenantEndpoint.cs new file mode 100644 index 0000000..d236767 --- /dev/null +++ b/src/EntKube.Identity/Features/CreateTenant/CreateTenantEndpoint.cs @@ -0,0 +1,28 @@ +using EntKube.SharedKernel.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace EntKube.Identity.Features.CreateTenant; + +/// +/// Maps POST /api/tenants — creates a new tenant organization. +/// +public static class CreateTenantEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("/api/tenants", async ( + [FromBody] CreateTenantRequest request, + [FromServices] CreateTenantHandler handler, + CancellationToken ct) => + { + EntKube.SharedKernel.Domain.Result result = await handler.HandleAsync(request, ct); + + if (result.IsFailure) + { + return Results.BadRequest(ApiResponse.Fail(result.Error!)); + } + + return Results.Created($"/api/tenants/{result.Value}", ApiResponse.Ok(result.Value!)); + }); + } +} diff --git a/src/EntKube.Identity/Features/CreateTenant/CreateTenantHandler.cs b/src/EntKube.Identity/Features/CreateTenant/CreateTenantHandler.cs new file mode 100644 index 0000000..a8235e9 --- /dev/null +++ b/src/EntKube.Identity/Features/CreateTenant/CreateTenantHandler.cs @@ -0,0 +1,59 @@ +using EntKube.Identity.Domain; +using EntKube.SharedKernel.Domain; + +namespace EntKube.Identity.Features.CreateTenant; + +/// +/// Handles creation of a new tenant. When a user signs up or an admin creates +/// a new organization, this handler validates the request, ensures the slug is +/// unique, creates the tenant aggregate, and persists it. The creating user +/// becomes the first admin of the tenant. +/// +public class CreateTenantHandler +{ + private readonly ITenantRepository repository; + + public CreateTenantHandler(ITenantRepository repository) + { + this.repository = repository; + } + + public async Task> HandleAsync(CreateTenantRequest request, CancellationToken ct = default) + { + // Validate required fields. + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Result.Failure("Tenant name is required."); + } + + if (string.IsNullOrWhiteSpace(request.Slug)) + { + return Result.Failure("Tenant slug is required."); + } + + if (request.CreatingUserId == Guid.Empty) + { + return Result.Failure("Creating user ID is required."); + } + + // Ensure no other tenant already uses this slug. Slugs are used in URLs + // and must be globally unique across the platform. + + Tenant? existing = await repository.GetBySlugAsync(request.Slug.ToLowerInvariant(), ct); + + if (existing is not null) + { + return Result.Failure($"A tenant with slug '{request.Slug}' already exists."); + } + + // Create the tenant aggregate and persist it. + + Tenant tenant = Tenant.Create(request.Name, request.Slug, request.CreatingUserId); + await repository.AddAsync(tenant, ct); + + return Result.Success(tenant.Id); + } +} + +public record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId); diff --git a/src/EntKube.Identity/Infrastructure/InMemoryTenantRepository.cs b/src/EntKube.Identity/Infrastructure/InMemoryTenantRepository.cs new file mode 100644 index 0000000..1eee6e5 --- /dev/null +++ b/src/EntKube.Identity/Infrastructure/InMemoryTenantRepository.cs @@ -0,0 +1,41 @@ +using EntKube.Identity.Domain; + +namespace EntKube.Identity.Infrastructure; + +/// +/// In-memory implementation of the tenant repository for local development. +/// Production will use EF Core with PostgreSQL. +/// +public class InMemoryTenantRepository : ITenantRepository +{ + private readonly List tenants = new(); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + Tenant? tenant = tenants.FirstOrDefault(t => t.Id == id); + return Task.FromResult(tenant); + } + + public Task GetBySlugAsync(string slug, CancellationToken ct = default) + { + Tenant? tenant = tenants.FirstOrDefault(t => t.Slug == slug); + return Task.FromResult(tenant); + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + IReadOnlyList result = tenants.AsReadOnly(); + return Task.FromResult(result); + } + + public Task AddAsync(Tenant tenant, CancellationToken ct = default) + { + tenants.Add(tenant); + return Task.CompletedTask; + } + + public Task UpdateAsync(Tenant tenant, CancellationToken ct = default) + { + return Task.CompletedTask; + } +} diff --git a/src/EntKube.Identity/Program.cs b/src/EntKube.Identity/Program.cs new file mode 100644 index 0000000..70a5620 --- /dev/null +++ b/src/EntKube.Identity/Program.cs @@ -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(); + builder.Services.AddScoped(); + builder.Services.AddOpenApi(); + + WebApplication app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + } + + app.UseHttpsRedirection(); + + // Map feature endpoints. + + CreateTenantEndpoint.Map(app); + + app.Run(); + } +} diff --git a/src/EntKube.Identity/Properties/launchSettings.json b/src/EntKube.Identity/Properties/launchSettings.json new file mode 100644 index 0000000..37dfbd5 --- /dev/null +++ b/src/EntKube.Identity/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/EntKube/EntKube/appsettings.Development.json b/src/EntKube.Identity/appsettings.Development.json similarity index 100% rename from EntKube/EntKube/appsettings.Development.json rename to src/EntKube.Identity/appsettings.Development.json diff --git a/src/EntKube.Identity/appsettings.json b/src/EntKube.Identity/appsettings.json new file mode 100644 index 0000000..d0111f5 --- /dev/null +++ b/src/EntKube.Identity/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/EntKube.Provisioning/Domain/IServiceInstanceRepository.cs b/src/EntKube.Provisioning/Domain/IServiceInstanceRepository.cs new file mode 100644 index 0000000..2067e63 --- /dev/null +++ b/src/EntKube.Provisioning/Domain/IServiceInstanceRepository.cs @@ -0,0 +1,13 @@ +namespace EntKube.Provisioning.Domain; + +/// +/// Defines how the provisioning service persists and retrieves service instances. +/// +public interface IServiceInstanceRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task AddAsync(ServiceInstance instance, CancellationToken ct = default); + Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default); +} diff --git a/src/EntKube.Provisioning/Domain/ServiceInstance.cs b/src/EntKube.Provisioning/Domain/ServiceInstance.cs new file mode 100644 index 0000000..f832939 --- /dev/null +++ b/src/EntKube.Provisioning/Domain/ServiceInstance.cs @@ -0,0 +1,95 @@ +namespace EntKube.Provisioning.Domain; + +/// +/// 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. +/// +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() { } + + /// + /// 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. + /// + 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 + }; + } + + /// + /// The reconciliation loop calls this after successfully deploying or verifying + /// the service is running on the cluster. + /// + public void MarkRunning() + { + CurrentState = ServiceState.Running; + LastReconcileAt = DateTimeOffset.UtcNow; + } + + /// + /// When the reconciliation loop detects the service is degraded or unreachable. + /// + public void MarkDegraded() + { + CurrentState = ServiceState.Degraded; + LastReconcileAt = DateTimeOffset.UtcNow; + } + + /// + /// A tenant requests decommissioning of the service. We set the desired state + /// to Decommissioned and the reconciliation loop will handle teardown. + /// + public void RequestDecommission() + { + DesiredState = ServiceState.Decommissioned; + } +} + +public enum ServiceType +{ + MinIO, + CloudNativePG, + Keycloak +} + +public enum ServiceState +{ + Pending, + Running, + Degraded, + Decommissioned +} diff --git a/src/EntKube.Provisioning/EntKube.Provisioning.csproj b/src/EntKube.Provisioning/EntKube.Provisioning.csproj new file mode 100644 index 0000000..86d7ffb --- /dev/null +++ b/src/EntKube.Provisioning/EntKube.Provisioning.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/EntKube.Provisioning/EntKube.Provisioning.http b/src/EntKube.Provisioning/EntKube.Provisioning.http new file mode 100644 index 0000000..410ceb6 --- /dev/null +++ b/src/EntKube.Provisioning/EntKube.Provisioning.http @@ -0,0 +1,6 @@ +@EntKube.Provisioning_HostAddress = http://localhost:5260 + +GET {{EntKube.Provisioning_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/EntKube.Provisioning/Features/GetServices/GetServicesEndpoint.cs b/src/EntKube.Provisioning/Features/GetServices/GetServicesEndpoint.cs new file mode 100644 index 0000000..24ee946 --- /dev/null +++ b/src/EntKube.Provisioning/Features/GetServices/GetServicesEndpoint.cs @@ -0,0 +1,44 @@ +using EntKube.Provisioning.Domain; +using EntKube.SharedKernel.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace EntKube.Provisioning.Features.GetServices; + +/// +/// Lists all provisioned service instances. Optionally filtered by cluster. +/// +public static class GetServicesEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("/api/services", async ( + [FromQuery] Guid? clusterId, + [FromServices] IServiceInstanceRepository repository, + CancellationToken ct) => + { + IReadOnlyList instances = clusterId.HasValue + ? await repository.GetByClusterIdAsync(clusterId.Value, ct) + : await repository.GetAllAsync(ct); + + List summaries = instances.Select(s => new ServiceSummary( + s.Id, + s.ClusterId, + s.ServiceType.ToString(), + s.Name, + s.Namespace, + s.CurrentState.ToString(), + s.DesiredState.ToString())).ToList(); + + return Results.Ok(ApiResponse>.Ok(summaries)); + }); + } +} + +public record ServiceSummary( + Guid Id, + Guid ClusterId, + string ServiceType, + string Name, + string Namespace, + string CurrentState, + string DesiredState); diff --git a/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceEndpoint.cs b/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceEndpoint.cs new file mode 100644 index 0000000..ca02cd1 --- /dev/null +++ b/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceEndpoint.cs @@ -0,0 +1,29 @@ +using EntKube.Provisioning.Domain; +using EntKube.SharedKernel.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace EntKube.Provisioning.Features.ProvisionService; + +/// +/// Maps POST /api/services — creates a new provisioning request for a shared service. +/// +public static class ProvisionServiceEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("/api/services", async ( + [FromBody] ProvisionServiceRequest request, + [FromServices] ProvisionServiceHandler handler, + CancellationToken ct) => + { + EntKube.SharedKernel.Domain.Result result = await handler.HandleAsync(request, ct); + + if (result.IsFailure) + { + return Results.BadRequest(ApiResponse.Fail(result.Error!)); + } + + return Results.Created($"/api/services/{result.Value}", ApiResponse.Ok(result.Value!)); + }); + } +} diff --git a/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceHandler.cs b/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceHandler.cs new file mode 100644 index 0000000..17e1358 --- /dev/null +++ b/src/EntKube.Provisioning/Features/ProvisionService/ProvisionServiceHandler.cs @@ -0,0 +1,61 @@ +using EntKube.Provisioning.Domain; +using EntKube.SharedKernel.Domain; + +namespace EntKube.Provisioning.Features.ProvisionService; + +/// +/// Handles requests to provision a new shared service on a cluster. A tenant admin +/// selects a service type (MinIO, CloudNativePG, Keycloak), provides a name and +/// target namespace, and we create the ServiceInstance aggregate. The actual deployment +/// is handled asynchronously by the reconciliation background service. +/// +public class ProvisionServiceHandler +{ + private readonly IServiceInstanceRepository repository; + + public ProvisionServiceHandler(IServiceInstanceRepository repository) + { + this.repository = repository; + } + + public async Task> HandleAsync(ProvisionServiceRequest request, CancellationToken ct = default) + { + // Validate the request contains all required fields. + + if (request.ClusterId == Guid.Empty) + { + return Result.Failure("Cluster ID is required."); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Result.Failure("Service name is required."); + } + + if (string.IsNullOrWhiteSpace(request.Namespace)) + { + return Result.Failure("Namespace is required."); + } + + // Create the service instance aggregate. The domain enforces any invariants + // about valid service configurations. + + ServiceInstance instance = ServiceInstance.Provision( + request.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); diff --git a/src/EntKube.Provisioning/Infrastructure/InMemoryServiceInstanceRepository.cs b/src/EntKube.Provisioning/Infrastructure/InMemoryServiceInstanceRepository.cs new file mode 100644 index 0000000..d717fb9 --- /dev/null +++ b/src/EntKube.Provisioning/Infrastructure/InMemoryServiceInstanceRepository.cs @@ -0,0 +1,41 @@ +using EntKube.Provisioning.Domain; + +namespace EntKube.Provisioning.Infrastructure; + +/// +/// In-memory implementation of the service instance repository for local development. +/// Production will use EF Core with PostgreSQL. +/// +public class InMemoryServiceInstanceRepository : IServiceInstanceRepository +{ + private readonly List instances = new(); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + ServiceInstance? instance = instances.FirstOrDefault(i => i.Id == id); + return Task.FromResult(instance); + } + + public Task> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default) + { + IReadOnlyList result = instances.Where(i => i.ClusterId == clusterId).ToList().AsReadOnly(); + return Task.FromResult(result); + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + IReadOnlyList result = instances.AsReadOnly(); + return Task.FromResult(result); + } + + public Task AddAsync(ServiceInstance instance, CancellationToken ct = default) + { + instances.Add(instance); + return Task.CompletedTask; + } + + public Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default) + { + return Task.CompletedTask; + } +} diff --git a/src/EntKube.Provisioning/Program.cs b/src/EntKube.Provisioning/Program.cs new file mode 100644 index 0000000..ac81070 --- /dev/null +++ b/src/EntKube.Provisioning/Program.cs @@ -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(); + builder.Services.AddScoped(); + 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(); + } +} diff --git a/src/EntKube.Provisioning/Properties/launchSettings.json b/src/EntKube.Provisioning/Properties/launchSettings.json new file mode 100644 index 0000000..56892d9 --- /dev/null +++ b/src/EntKube.Provisioning/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/EntKube/EntKube.Client/wwwroot/appsettings.json b/src/EntKube.Provisioning/appsettings.Development.json similarity index 100% rename from EntKube/EntKube.Client/wwwroot/appsettings.json rename to src/EntKube.Provisioning/appsettings.Development.json diff --git a/src/EntKube.Provisioning/appsettings.json b/src/EntKube.Provisioning/appsettings.json new file mode 100644 index 0000000..d0111f5 --- /dev/null +++ b/src/EntKube.Provisioning/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/EntKube.SharedKernel/Contracts/ApiResponse.cs b/src/EntKube.SharedKernel/Contracts/ApiResponse.cs new file mode 100644 index 0000000..287bd73 --- /dev/null +++ b/src/EntKube.SharedKernel/Contracts/ApiResponse.cs @@ -0,0 +1,26 @@ +namespace EntKube.SharedKernel.Contracts; + +/// +/// Standard API response envelope used by all EntKube microservices. +/// Every HTTP response wraps its payload in this structure so clients +/// always know where to find the data, error messages, and metadata. +/// +public record ApiResponse +{ + public bool Success { get; init; } + public T? Data { get; init; } + public string? Error { get; init; } + public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow; + + public static ApiResponse Ok(T data) => new() + { + Success = true, + Data = data + }; + + public static ApiResponse Fail(string error) => new() + { + Success = false, + Error = error + }; +} diff --git a/src/EntKube.SharedKernel/Domain/Entity.cs b/src/EntKube.SharedKernel/Domain/Entity.cs new file mode 100644 index 0000000..94289ee --- /dev/null +++ b/src/EntKube.SharedKernel/Domain/Entity.cs @@ -0,0 +1,24 @@ +namespace EntKube.SharedKernel.Domain; + +/// +/// Every domain entity in EntKube has a unique identifier. This base class +/// provides that identity and equality comparison so that two entities +/// are considered the same if they share the same Id — regardless of +/// whether their other properties differ. +/// +public abstract class Entity where TId : notnull +{ + public TId Id { get; protected set; } = default!; + + public override bool Equals(object? obj) + { + if (obj is not Entity other) + { + return false; + } + + return Id.Equals(other.Id); + } + + public override int GetHashCode() => Id.GetHashCode(); +} diff --git a/src/EntKube.SharedKernel/Domain/Result.cs b/src/EntKube.SharedKernel/Domain/Result.cs new file mode 100644 index 0000000..75a927b --- /dev/null +++ b/src/EntKube.SharedKernel/Domain/Result.cs @@ -0,0 +1,43 @@ +namespace EntKube.SharedKernel.Domain; + +/// +/// A Result represents the outcome of an operation that can either succeed or fail. +/// Instead of throwing exceptions for expected failure scenarios (validation errors, +/// business rule violations, external service failures), we return a Result that +/// the caller can inspect and handle gracefully. +/// +public class Result +{ + public bool IsSuccess { get; } + public string? Error { get; } + public bool IsFailure => !IsSuccess; + + protected Result(bool isSuccess, string? error) + { + IsSuccess = isSuccess; + Error = error; + } + + public static Result Success() => new(true, null); + + public static Result Failure(string error) => new(false, error); + + public static Result Success(T value) => new(value, true, null); + + public static Result Failure(string error) => new(default, false, error); +} + +/// +/// A typed Result that carries a value on success. When the operation succeeds, +/// the Value property contains the result. When it fails, Error describes what went wrong. +/// +public class Result : Result +{ + public T? Value { get; } + + internal Result(T? value, bool isSuccess, string? error) + : base(isSuccess, error) + { + Value = value; + } +} diff --git a/src/EntKube.SharedKernel/EntKube.SharedKernel.csproj b/src/EntKube.SharedKernel/EntKube.SharedKernel.csproj new file mode 100644 index 0000000..770d62a --- /dev/null +++ b/src/EntKube.SharedKernel/EntKube.SharedKernel.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/EntKube/EntKube.Client/EntKube.Client.csproj b/src/EntKube.Web.Client/EntKube.Web.Client.csproj similarity index 88% rename from EntKube/EntKube.Client/EntKube.Client.csproj rename to src/EntKube.Web.Client/EntKube.Web.Client.csproj index a96e28d..8de45fa 100644 --- a/EntKube/EntKube.Client/EntKube.Client.csproj +++ b/src/EntKube.Web.Client/EntKube.Web.Client.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/EntKube/EntKube/Components/Layout/MainLayout.razor b/src/EntKube.Web.Client/Layout/MainLayout.razor similarity index 100% rename from EntKube/EntKube/Components/Layout/MainLayout.razor rename to src/EntKube.Web.Client/Layout/MainLayout.razor diff --git a/EntKube/EntKube/Components/Layout/MainLayout.razor.css b/src/EntKube.Web.Client/Layout/MainLayout.razor.css similarity index 100% rename from EntKube/EntKube/Components/Layout/MainLayout.razor.css rename to src/EntKube.Web.Client/Layout/MainLayout.razor.css diff --git a/EntKube/EntKube/Components/Layout/NavMenu.razor b/src/EntKube.Web.Client/Layout/NavMenu.razor similarity index 98% rename from EntKube/EntKube/Components/Layout/NavMenu.razor rename to src/EntKube.Web.Client/Layout/NavMenu.razor index 759175b..c7d922a 100644 --- a/EntKube/EntKube/Components/Layout/NavMenu.razor +++ b/src/EntKube.Web.Client/Layout/NavMenu.razor @@ -4,7 +4,7 @@ diff --git a/EntKube/EntKube/Components/Layout/NavMenu.razor.css b/src/EntKube.Web.Client/Layout/NavMenu.razor.css similarity index 100% rename from EntKube/EntKube/Components/Layout/NavMenu.razor.css rename to src/EntKube.Web.Client/Layout/NavMenu.razor.css diff --git a/EntKube/EntKube/Components/Layout/ReconnectModal.razor b/src/EntKube.Web.Client/Layout/ReconnectModal.razor similarity index 83% rename from EntKube/EntKube/Components/Layout/ReconnectModal.razor rename to src/EntKube.Web.Client/Layout/ReconnectModal.razor index 84c5d61..50d55c3 100644 --- a/EntKube/EntKube/Components/Layout/ReconnectModal.razor +++ b/src/EntKube.Web.Client/Layout/ReconnectModal.razor @@ -1,4 +1,4 @@ - +
@@ -21,11 +21,11 @@

The session has been paused by the server.

-

- Failed to resume the session.
Please retry or reload the page. -

- +

+ Failed to resume the session.
Please reload the page. +

diff --git a/EntKube/EntKube/Components/Layout/ReconnectModal.razor.css b/src/EntKube.Web.Client/Layout/ReconnectModal.razor.css similarity index 100% rename from EntKube/EntKube/Components/Layout/ReconnectModal.razor.css rename to src/EntKube.Web.Client/Layout/ReconnectModal.razor.css diff --git a/EntKube/EntKube/Components/Layout/ReconnectModal.razor.js b/src/EntKube.Web.Client/Layout/ReconnectModal.razor.js similarity index 95% rename from EntKube/EntKube/Components/Layout/ReconnectModal.razor.js rename to src/EntKube.Web.Client/Layout/ReconnectModal.razor.js index 08063ee..ffbe505 100644 --- a/EntKube/EntKube/Components/Layout/ReconnectModal.razor.js +++ b/src/EntKube.Web.Client/Layout/ReconnectModal.razor.js @@ -52,7 +52,7 @@ async function resume() { location.reload(); } } catch { - reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + location.reload(); } } diff --git a/EntKube/EntKube.Client/Pages/Auth.razor b/src/EntKube.Web.Client/Pages/Auth.razor similarity index 88% rename from EntKube/EntKube.Client/Pages/Auth.razor rename to src/EntKube.Web.Client/Pages/Auth.razor index e243741..fd81666 100644 --- a/EntKube/EntKube.Client/Pages/Auth.razor +++ b/src/EntKube.Web.Client/Pages/Auth.razor @@ -3,7 +3,6 @@ @using Microsoft.AspNetCore.Authorization @attribute [Authorize] -@rendermode InteractiveAuto Auth diff --git a/EntKube/EntKube.Client/Pages/Counter.razor b/src/EntKube.Web.Client/Pages/Counter.razor similarity index 91% rename from EntKube/EntKube.Client/Pages/Counter.razor rename to src/EntKube.Web.Client/Pages/Counter.razor index 1c21c80..26e24e6 100644 --- a/EntKube/EntKube.Client/Pages/Counter.razor +++ b/src/EntKube.Web.Client/Pages/Counter.razor @@ -1,5 +1,4 @@ @page "/counter" -@rendermode InteractiveAuto Counter diff --git a/EntKube/EntKube/Components/Pages/Home.razor b/src/EntKube.Web.Client/Pages/Home.razor similarity index 100% rename from EntKube/EntKube/Components/Pages/Home.razor rename to src/EntKube.Web.Client/Pages/Home.razor diff --git a/EntKube/EntKube/Components/Pages/NotFound.razor b/src/EntKube.Web.Client/Pages/NotFound.razor similarity index 100% rename from EntKube/EntKube/Components/Pages/NotFound.razor rename to src/EntKube.Web.Client/Pages/NotFound.razor diff --git a/EntKube/EntKube/Components/Pages/Weather.razor b/src/EntKube.Web.Client/Pages/Weather.razor similarity index 94% rename from EntKube/EntKube/Components/Pages/Weather.razor rename to src/EntKube.Web.Client/Pages/Weather.razor index e3f5324..be6d06d 100644 --- a/EntKube/EntKube/Components/Pages/Weather.razor +++ b/src/EntKube.Web.Client/Pages/Weather.razor @@ -1,5 +1,4 @@ @page "/weather" -@attribute [StreamRendering] Weather @@ -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); diff --git a/src/EntKube.Web.Client/Program.cs b/src/EntKube.Web.Client/Program.cs new file mode 100644 index 0000000..344cb11 --- /dev/null +++ b/src/EntKube.Web.Client/Program.cs @@ -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(); diff --git a/EntKube/EntKube.Client/RedirectToLogin.razor b/src/EntKube.Web.Client/RedirectToLogin.razor similarity index 100% rename from EntKube/EntKube.Client/RedirectToLogin.razor rename to src/EntKube.Web.Client/RedirectToLogin.razor diff --git a/EntKube/EntKube/Components/Routes.razor b/src/EntKube.Web.Client/Routes.razor similarity index 68% rename from EntKube/EntKube/Components/Routes.razor rename to src/EntKube.Web.Client/Routes.razor index 32e35ee..d8121a8 100644 --- a/EntKube/EntKube/Components/Routes.razor +++ b/src/EntKube.Web.Client/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/EntKube/EntKube.Client/_Imports.razor b/src/EntKube.Web.Client/_Imports.razor similarity index 86% rename from EntKube/EntKube.Client/_Imports.razor rename to src/EntKube.Web.Client/_Imports.razor index 6785b30..67fc7d8 100644 --- a/EntKube/EntKube.Client/_Imports.razor +++ b/src/EntKube.Web.Client/_Imports.razor @@ -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 diff --git a/src/EntKube.Web.Client/wwwroot/appsettings.Development.json b/src/EntKube.Web.Client/wwwroot/appsettings.Development.json new file mode 100644 index 0000000..2d6cd3d --- /dev/null +++ b/src/EntKube.Web.Client/wwwroot/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/EntKube.Web.Client/wwwroot/appsettings.json b/src/EntKube.Web.Client/wwwroot/appsettings.json new file mode 100644 index 0000000..2d6cd3d --- /dev/null +++ b/src/EntKube.Web.Client/wwwroot/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/EntKube.Web/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs b/src/EntKube.Web/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..0e35879 --- /dev/null +++ b/src/EntKube.Web/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs @@ -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 signInManager, + [FromForm] string provider, + [FromForm] string returnUrl) => + { + IEnumerable> 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 signInManager, + [FromForm] string returnUrl) => + { + await signInManager.SignOutAsync(); + return TypedResults.LocalRedirect($"~/{returnUrl}"); + }); + + accountGroup.MapPost("/PasskeyCreationOptions", async ( + HttpContext context, + [FromServices] UserManager userManager, + [FromServices] SignInManager 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 userManager, + [FromServices] SignInManager 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 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(); + var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); + + manageGroup.MapPost("/DownloadPersonalData", async ( + HttpContext context, + [FromServices] UserManager 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(); + 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; + } +} diff --git a/src/EntKube.Web/Components/Account/IdentityNoOpEmailSender.cs b/src/EntKube.Web/Components/Account/IdentityNoOpEmailSender.cs new file mode 100644 index 0000000..15d37e7 --- /dev/null +++ b/src/EntKube.Web/Components/Account/IdentityNoOpEmailSender.cs @@ -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 +{ + 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 clicking here."); + + public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); + + public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => + emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); +} diff --git a/src/EntKube.Web/Components/Account/IdentityRedirectManager.cs b/src/EntKube.Web/Components/Account/IdentityRedirectManager.cs new file mode 100644 index 0000000..6c9d01d --- /dev/null +++ b/src/EntKube.Web/Components/Account/IdentityRedirectManager.cs @@ -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 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 userManager, HttpContext context) + => RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context); +} diff --git a/src/EntKube.Web/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs b/src/EntKube.Web/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs new file mode 100644 index 0000000..49ff0a9 --- /dev/null +++ b/src/EntKube.Web/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs @@ -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 options) + : RevalidatingServerAuthenticationStateProvider(loggerFactory) +{ + protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); + + protected override async Task 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>(); + return await ValidateSecurityStampAsync(userManager, authenticationState.User); + } + + private async Task ValidateSecurityStampAsync(UserManager 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; + } + } +} diff --git a/EntKube/EntKube/Components/Account/Pages/AccessDenied.razor b/src/EntKube.Web/Components/Account/Pages/AccessDenied.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/AccessDenied.razor rename to src/EntKube.Web/Components/Account/Pages/AccessDenied.razor diff --git a/EntKube/EntKube/Components/Account/Pages/ConfirmEmail.razor b/src/EntKube.Web/Components/Account/Pages/ConfirmEmail.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Pages/ConfirmEmail.razor rename to src/EntKube.Web/Components/Account/Pages/ConfirmEmail.razor index e518acd..260e630 100644 --- a/EntKube/EntKube/Components/Account/Pages/ConfirmEmail.razor +++ b/src/EntKube.Web/Components/Account/Pages/ConfirmEmail.razor @@ -3,7 +3,7 @@ @using System.Text @using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.WebUtilities -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject IdentityRedirectManager RedirectManager diff --git a/EntKube/EntKube/Components/Account/Pages/ConfirmEmailChange.razor b/src/EntKube.Web/Components/Account/Pages/ConfirmEmailChange.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Pages/ConfirmEmailChange.razor rename to src/EntKube.Web/Components/Account/Pages/ConfirmEmailChange.razor index 8012cc3..7f0e9f2 100644 --- a/EntKube/EntKube/Components/Account/Pages/ConfirmEmailChange.razor +++ b/src/EntKube.Web/Components/Account/Pages/ConfirmEmailChange.razor @@ -3,7 +3,7 @@ @using System.Text @using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.WebUtilities -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/ExternalLogin.razor b/src/EntKube.Web/Components/Account/Pages/ExternalLogin.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/ExternalLogin.razor rename to src/EntKube.Web/Components/Account/Pages/ExternalLogin.razor index ca5225a..c4c1794 100644 --- a/EntKube/EntKube/Components/Account/Pages/ExternalLogin.razor +++ b/src/EntKube.Web/Components/Account/Pages/ExternalLogin.razor @@ -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 SignInManager @inject UserManager UserManager diff --git a/EntKube/EntKube/Components/Account/Pages/ForgotPassword.razor b/src/EntKube.Web/Components/Account/Pages/ForgotPassword.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/ForgotPassword.razor rename to src/EntKube.Web/Components/Account/Pages/ForgotPassword.razor index faf4732..782887c 100644 --- a/EntKube/EntKube/Components/Account/Pages/ForgotPassword.razor +++ b/src/EntKube.Web/Components/Account/Pages/ForgotPassword.razor @@ -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 UserManager @inject IEmailSender EmailSender diff --git a/EntKube/EntKube/Components/Account/Pages/ForgotPasswordConfirmation.razor b/src/EntKube.Web/Components/Account/Pages/ForgotPasswordConfirmation.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/ForgotPasswordConfirmation.razor rename to src/EntKube.Web/Components/Account/Pages/ForgotPasswordConfirmation.razor diff --git a/EntKube/EntKube/Components/Account/Pages/InvalidPasswordReset.razor b/src/EntKube.Web/Components/Account/Pages/InvalidPasswordReset.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/InvalidPasswordReset.razor rename to src/EntKube.Web/Components/Account/Pages/InvalidPasswordReset.razor diff --git a/EntKube/EntKube/Components/Account/Pages/InvalidUser.razor b/src/EntKube.Web/Components/Account/Pages/InvalidUser.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/InvalidUser.razor rename to src/EntKube.Web/Components/Account/Pages/InvalidUser.razor diff --git a/EntKube/EntKube/Components/Account/Pages/Lockout.razor b/src/EntKube.Web/Components/Account/Pages/Lockout.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/Lockout.razor rename to src/EntKube.Web/Components/Account/Pages/Lockout.razor diff --git a/EntKube/EntKube/Components/Account/Pages/Login.razor b/src/EntKube.Web/Components/Account/Pages/Login.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Login.razor rename to src/EntKube.Web/Components/Account/Pages/Login.razor index 1b4fda6..7b13744 100644 --- a/EntKube/EntKube/Components/Account/Pages/Login.razor +++ b/src/EntKube.Web/Components/Account/Pages/Login.razor @@ -3,7 +3,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authentication @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/LoginWith2fa.razor b/src/EntKube.Web/Components/Account/Pages/LoginWith2fa.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/LoginWith2fa.razor rename to src/EntKube.Web/Components/Account/Pages/LoginWith2fa.razor index fcab27d..16856e0 100644 --- a/EntKube/EntKube/Components/Account/Pages/LoginWith2fa.razor +++ b/src/EntKube.Web/Components/Account/Pages/LoginWith2fa.razor @@ -2,7 +2,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject SignInManager SignInManager @inject UserManager UserManager diff --git a/EntKube/EntKube/Components/Account/Pages/LoginWithRecoveryCode.razor b/src/EntKube.Web/Components/Account/Pages/LoginWithRecoveryCode.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/LoginWithRecoveryCode.razor rename to src/EntKube.Web/Components/Account/Pages/LoginWithRecoveryCode.razor index f74f06f..975f4a2 100644 --- a/EntKube/EntKube/Components/Account/Pages/LoginWithRecoveryCode.razor +++ b/src/EntKube.Web/Components/Account/Pages/LoginWithRecoveryCode.razor @@ -2,7 +2,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject SignInManager SignInManager @inject UserManager UserManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/ChangePassword.razor b/src/EntKube.Web/Components/Account/Pages/Manage/ChangePassword.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/ChangePassword.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/ChangePassword.razor index 93d874d..839916b 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/ChangePassword.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/ChangePassword.razor @@ -2,7 +2,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/DeletePersonalData.razor b/src/EntKube.Web/Components/Account/Pages/Manage/DeletePersonalData.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/DeletePersonalData.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/DeletePersonalData.razor index c92cdda..963fabc 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/DeletePersonalData.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/DeletePersonalData.razor @@ -2,7 +2,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/Disable2fa.razor b/src/EntKube.Web/Components/Account/Pages/Manage/Disable2fa.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/Disable2fa.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/Disable2fa.razor index 710aada..20a0014 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/Disable2fa.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/Disable2fa.razor @@ -1,7 +1,7 @@ @page "/Account/Manage/Disable2fa" @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject IdentityRedirectManager RedirectManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/Email.razor b/src/EntKube.Web/Components/Account/Pages/Manage/Email.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/Email.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/Email.razor index 4535bce..0abae7e 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/Email.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/Email.razor @@ -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 UserManager @inject IEmailSender EmailSender diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/EnableAuthenticator.razor b/src/EntKube.Web/Components/Account/Pages/Manage/EnableAuthenticator.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/EnableAuthenticator.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/EnableAuthenticator.razor index 0ab76f9..3d8f691 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/EnableAuthenticator.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/EnableAuthenticator.razor @@ -5,7 +5,7 @@ @using System.Text @using System.Text.Encodings.Web @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject UrlEncoder UrlEncoder diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/ExternalLogins.razor b/src/EntKube.Web/Components/Account/Pages/Manage/ExternalLogins.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/ExternalLogins.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/ExternalLogins.razor index b1c6c75..5a48576 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/ExternalLogins.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/ExternalLogins.razor @@ -2,7 +2,7 @@ @using Microsoft.AspNetCore.Authentication @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor b/src/EntKube.Web/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor index 5740d5b..23bc742 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/GenerateRecoveryCodes.razor @@ -1,7 +1,7 @@ @page "/Account/Manage/GenerateRecoveryCodes" @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject IdentityRedirectManager RedirectManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/Index.razor b/src/EntKube.Web/Components/Account/Pages/Manage/Index.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/Index.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/Index.razor index 7e249de..b9d6972 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/Index.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/Index.razor @@ -2,7 +2,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/Passkeys.razor b/src/EntKube.Web/Components/Account/Pages/Manage/Passkeys.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/Passkeys.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/Passkeys.razor index f3a3121..25c30bc 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/Passkeys.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/Passkeys.razor @@ -1,6 +1,6 @@ @page "/Account/Manage/Passkeys" -@using EntKube.Data +@using EntKube.Web.Data @using Microsoft.AspNetCore.Identity @using System.ComponentModel.DataAnnotations @using System.Buffers.Text diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/PersonalData.razor b/src/EntKube.Web/Components/Account/Pages/Manage/PersonalData.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Pages/Manage/PersonalData.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/PersonalData.razor index 1764c45..fc7a05f 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/PersonalData.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/PersonalData.razor @@ -1,7 +1,7 @@ @page "/Account/Manage/PersonalData" @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject IdentityRedirectManager RedirectManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/RenamePasskey.razor b/src/EntKube.Web/Components/Account/Pages/Manage/RenamePasskey.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/RenamePasskey.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/RenamePasskey.razor index 4d031e6..70f67d2 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/RenamePasskey.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/RenamePasskey.razor @@ -1,6 +1,6 @@ @page "/Account/Manage/RenamePasskey/{Id}" -@using EntKube.Data +@using EntKube.Web.Data @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity @using System.Buffers.Text diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/ResetAuthenticator.razor b/src/EntKube.Web/Components/Account/Pages/Manage/ResetAuthenticator.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Pages/Manage/ResetAuthenticator.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/ResetAuthenticator.razor index 6e9e68e..2688966 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/ResetAuthenticator.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/ResetAuthenticator.razor @@ -1,7 +1,7 @@ @page "/Account/Manage/ResetAuthenticator" @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/SetPassword.razor b/src/EntKube.Web/Components/Account/Pages/Manage/SetPassword.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/SetPassword.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/SetPassword.razor index bbe1815..6cb15cb 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/SetPassword.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/SetPassword.razor @@ -2,7 +2,7 @@ @using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/TwoFactorAuthentication.razor b/src/EntKube.Web/Components/Account/Pages/Manage/TwoFactorAuthentication.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Manage/TwoFactorAuthentication.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/TwoFactorAuthentication.razor index 3694015..2625793 100644 --- a/EntKube/EntKube/Components/Account/Pages/Manage/TwoFactorAuthentication.razor +++ b/src/EntKube.Web/Components/Account/Pages/Manage/TwoFactorAuthentication.razor @@ -2,7 +2,7 @@ @using Microsoft.AspNetCore.Http.Features @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Pages/Manage/_Imports.razor b/src/EntKube.Web/Components/Account/Pages/Manage/_Imports.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/Manage/_Imports.razor rename to src/EntKube.Web/Components/Account/Pages/Manage/_Imports.razor diff --git a/EntKube/EntKube/Components/Account/Pages/Register.razor b/src/EntKube.Web/Components/Account/Pages/Register.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/Register.razor rename to src/EntKube.Web/Components/Account/Pages/Register.razor index 099d4fa..f7612cf 100644 --- a/EntKube/EntKube/Components/Account/Pages/Register.razor +++ b/src/EntKube.Web/Components/Account/Pages/Register.razor @@ -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 UserManager @inject IUserStore UserStore diff --git a/EntKube/EntKube/Components/Account/Pages/RegisterConfirmation.razor b/src/EntKube.Web/Components/Account/Pages/RegisterConfirmation.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Pages/RegisterConfirmation.razor rename to src/EntKube.Web/Components/Account/Pages/RegisterConfirmation.razor index a095a15..2423e2a 100644 --- a/EntKube/EntKube/Components/Account/Pages/RegisterConfirmation.razor +++ b/src/EntKube.Web/Components/Account/Pages/RegisterConfirmation.razor @@ -3,7 +3,7 @@ @using System.Text @using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.WebUtilities -@using EntKube.Data +@using EntKube.Web.Data @inject UserManager UserManager @inject IEmailSender EmailSender diff --git a/EntKube/EntKube/Components/Account/Pages/ResendEmailConfirmation.razor b/src/EntKube.Web/Components/Account/Pages/ResendEmailConfirmation.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/ResendEmailConfirmation.razor rename to src/EntKube.Web/Components/Account/Pages/ResendEmailConfirmation.razor index fb85097..6d51121 100644 --- a/EntKube/EntKube/Components/Account/Pages/ResendEmailConfirmation.razor +++ b/src/EntKube.Web/Components/Account/Pages/ResendEmailConfirmation.razor @@ -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 UserManager @inject IEmailSender EmailSender diff --git a/EntKube/EntKube/Components/Account/Pages/ResetPassword.razor b/src/EntKube.Web/Components/Account/Pages/ResetPassword.razor similarity index 99% rename from EntKube/EntKube/Components/Account/Pages/ResetPassword.razor rename to src/EntKube.Web/Components/Account/Pages/ResetPassword.razor index c8f1629..97e9615 100644 --- a/EntKube/EntKube/Components/Account/Pages/ResetPassword.razor +++ b/src/EntKube.Web/Components/Account/Pages/ResetPassword.razor @@ -4,7 +4,7 @@ @using System.Text @using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.WebUtilities -@using EntKube.Data +@using EntKube.Web.Data @inject IdentityRedirectManager RedirectManager @inject UserManager UserManager diff --git a/EntKube/EntKube/Components/Account/Pages/ResetPasswordConfirmation.razor b/src/EntKube.Web/Components/Account/Pages/ResetPasswordConfirmation.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Pages/ResetPasswordConfirmation.razor rename to src/EntKube.Web/Components/Account/Pages/ResetPasswordConfirmation.razor diff --git a/src/EntKube.Web/Components/Account/Pages/_Imports.razor b/src/EntKube.Web/Components/Account/Pages/_Imports.razor new file mode 100644 index 0000000..91e3ebb --- /dev/null +++ b/src/EntKube.Web/Components/Account/Pages/_Imports.razor @@ -0,0 +1,2 @@ +@using EntKube.Web.Components.Account.Shared +@attribute [ExcludeFromInteractiveRouting] diff --git a/src/EntKube.Web/Components/Account/PasskeyInputModel.cs b/src/EntKube.Web/Components/Account/PasskeyInputModel.cs new file mode 100644 index 0000000..0c86b98 --- /dev/null +++ b/src/EntKube.Web/Components/Account/PasskeyInputModel.cs @@ -0,0 +1,7 @@ +namespace EntKube.Web.Components.Account; + +public class PasskeyInputModel +{ + public string? CredentialJson { get; set; } + public string? Error { get; set; } +} diff --git a/src/EntKube.Web/Components/Account/PasskeyOperation.cs b/src/EntKube.Web/Components/Account/PasskeyOperation.cs new file mode 100644 index 0000000..dab1fe1 --- /dev/null +++ b/src/EntKube.Web/Components/Account/PasskeyOperation.cs @@ -0,0 +1,7 @@ +namespace EntKube.Web.Components.Account; + +public enum PasskeyOperation +{ + Create = 0, + Request = 1, +} diff --git a/EntKube/EntKube/Components/Account/Shared/ExternalLoginPicker.razor b/src/EntKube.Web/Components/Account/Shared/ExternalLoginPicker.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Shared/ExternalLoginPicker.razor rename to src/EntKube.Web/Components/Account/Shared/ExternalLoginPicker.razor index e0ba249..3d44889 100644 --- a/EntKube/EntKube/Components/Account/Shared/ExternalLoginPicker.razor +++ b/src/EntKube.Web/Components/Account/Shared/ExternalLoginPicker.razor @@ -1,6 +1,6 @@ @using Microsoft.AspNetCore.Authentication @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject SignInManager SignInManager @inject IdentityRedirectManager RedirectManager diff --git a/EntKube/EntKube/Components/Account/Shared/ManageLayout.razor b/src/EntKube.Web/Components/Account/Shared/ManageLayout.razor similarity index 87% rename from EntKube/EntKube/Components/Account/Shared/ManageLayout.razor rename to src/EntKube.Web/Components/Account/Shared/ManageLayout.razor index d62ed08..35bd7db 100644 --- a/EntKube/EntKube/Components/Account/Shared/ManageLayout.razor +++ b/src/EntKube.Web/Components/Account/Shared/ManageLayout.razor @@ -1,5 +1,5 @@ @inherits LayoutComponentBase -@layout EntKube.Components.Layout.MainLayout +@layout EntKube.Web.Client.Layout.MainLayout

Manage your account

diff --git a/EntKube/EntKube/Components/Account/Shared/ManageNavMenu.razor b/src/EntKube.Web/Components/Account/Shared/ManageNavMenu.razor similarity index 98% rename from EntKube/EntKube/Components/Account/Shared/ManageNavMenu.razor rename to src/EntKube.Web/Components/Account/Shared/ManageNavMenu.razor index b69e922..5e5d882 100644 --- a/EntKube/EntKube/Components/Account/Shared/ManageNavMenu.razor +++ b/src/EntKube.Web/Components/Account/Shared/ManageNavMenu.razor @@ -1,5 +1,5 @@ @using Microsoft.AspNetCore.Identity -@using EntKube.Data +@using EntKube.Web.Data @inject SignInManager SignInManager diff --git a/EntKube/EntKube/Components/Account/Shared/PasskeySubmit.razor b/src/EntKube.Web/Components/Account/Shared/PasskeySubmit.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Shared/PasskeySubmit.razor rename to src/EntKube.Web/Components/Account/Shared/PasskeySubmit.razor diff --git a/EntKube/EntKube/Components/Account/Shared/PasskeySubmit.razor.js b/src/EntKube.Web/Components/Account/Shared/PasskeySubmit.razor.js similarity index 100% rename from EntKube/EntKube/Components/Account/Shared/PasskeySubmit.razor.js rename to src/EntKube.Web/Components/Account/Shared/PasskeySubmit.razor.js diff --git a/EntKube/EntKube/Components/Account/Shared/ShowRecoveryCodes.razor b/src/EntKube.Web/Components/Account/Shared/ShowRecoveryCodes.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Shared/ShowRecoveryCodes.razor rename to src/EntKube.Web/Components/Account/Shared/ShowRecoveryCodes.razor diff --git a/EntKube/EntKube/Components/Account/Shared/StatusMessage.razor b/src/EntKube.Web/Components/Account/Shared/StatusMessage.razor similarity index 100% rename from EntKube/EntKube/Components/Account/Shared/StatusMessage.razor rename to src/EntKube.Web/Components/Account/Shared/StatusMessage.razor diff --git a/EntKube/EntKube/Components/App.razor b/src/EntKube.Web/Components/App.razor similarity index 62% rename from EntKube/EntKube/Components/App.razor rename to src/EntKube.Web/Components/App.razor index ac73ef2..decb00c 100644 --- a/EntKube/EntKube/Components/App.razor +++ b/src/EntKube.Web/Components/App.razor @@ -8,17 +8,25 @@ - + - + - + + +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + private IComponentRenderMode? PageRenderMode => + HttpContext.AcceptsInteractiveRouting() ? InteractiveAuto : null; +} diff --git a/EntKube/EntKube/Components/Pages/Error.razor b/src/EntKube.Web/Components/Pages/Error.razor similarity index 100% rename from EntKube/EntKube/Components/Pages/Error.razor rename to src/EntKube.Web/Components/Pages/Error.razor diff --git a/EntKube/EntKube/Components/_Imports.razor b/src/EntKube.Web/Components/_Imports.razor similarity index 78% rename from EntKube/EntKube/Components/_Imports.razor rename to src/EntKube.Web/Components/_Imports.razor index 9504213..cce9039 100644 --- a/EntKube/EntKube/Components/_Imports.razor +++ b/src/EntKube.Web/Components/_Imports.razor @@ -7,7 +7,7 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using EntKube -@using EntKube.Client -@using EntKube.Components -@using EntKube.Components.Layout +@using EntKube.Web +@using EntKube.Web.Client +@using EntKube.Web.Client.Layout +@using EntKube.Web.Components diff --git a/src/EntKube.Web/Data/ApplicationDbContext.cs b/src/EntKube.Web/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..45913f4 --- /dev/null +++ b/src/EntKube.Web/Data/ApplicationDbContext.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace EntKube.Web.Data; + +public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) +{ +} diff --git a/src/EntKube.Web/Data/ApplicationUser.cs b/src/EntKube.Web/Data/ApplicationUser.cs new file mode 100644 index 0000000..8602969 --- /dev/null +++ b/src/EntKube.Web/Data/ApplicationUser.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity; + +namespace EntKube.Web.Data; + +// Add profile data for application users by adding properties to the ApplicationUser class +public class ApplicationUser : IdentityUser +{ +} + diff --git a/EntKube/EntKube/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs similarity index 96% rename from EntKube/EntKube/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs rename to src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs index 9453db3..85a1442 100644 --- a/EntKube/EntKube/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs +++ b/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs @@ -1,14 +1,14 @@ // -using EntKube.Data; +using System; +using EntKube.Web.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System; #nullable disable -namespace EntKube.Migrations +namespace EntKube.Web.Migrations { [DbContext(typeof(ApplicationDbContext))] [Migration("00000000000000_CreateIdentitySchema")] @@ -20,7 +20,7 @@ namespace EntKube.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - modelBuilder.Entity("EntKube.Data.ApplicationUser", b => + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -245,7 +245,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -254,7 +254,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -263,7 +263,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -329,7 +329,7 @@ namespace EntKube.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -338,7 +338,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/EntKube/EntKube/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs similarity index 99% rename from EntKube/EntKube/Data/Migrations/00000000000000_CreateIdentitySchema.cs rename to src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs index 76bf9ac..ae99ca8 100644 --- a/EntKube/EntKube/Data/Migrations/00000000000000_CreateIdentitySchema.cs +++ b/src/EntKube.Web/Data/Migrations/00000000000000_CreateIdentitySchema.cs @@ -1,9 +1,9 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using System; +using System; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace EntKube.Migrations +namespace EntKube.Web.Migrations { /// public partial class CreateIdentitySchema : Migration diff --git a/EntKube/EntKube/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/EntKube.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs similarity index 96% rename from EntKube/EntKube/Data/Migrations/ApplicationDbContextModelSnapshot.cs rename to src/EntKube.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 88f418d..0238577 100644 --- a/EntKube/EntKube/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/EntKube.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,13 +1,13 @@ // -using EntKube.Data; +using System; +using EntKube.Web.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System; #nullable disable -namespace EntKube.Migrations +namespace EntKube.Web.Migrations { [DbContext(typeof(ApplicationDbContext))] partial class ApplicationDbContextModelSnapshot : ModelSnapshot @@ -17,7 +17,7 @@ namespace EntKube.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - modelBuilder.Entity("EntKube.Data.ApplicationUser", b => + modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b => { b.Property("Id") .HasColumnType("TEXT"); @@ -242,7 +242,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -251,7 +251,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -260,7 +260,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -326,7 +326,7 @@ namespace EntKube.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) @@ -335,7 +335,7 @@ namespace EntKube.Migrations modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { - b.HasOne("EntKube.Data.ApplicationUser", null) + b.HasOne("EntKube.Web.Data.ApplicationUser", null) .WithMany() .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) diff --git a/EntKube/EntKube/Data/app.db b/src/EntKube.Web/Data/app.db similarity index 100% rename from EntKube/EntKube/Data/app.db rename to src/EntKube.Web/Data/app.db diff --git a/EntKube/EntKube/EntKube.csproj b/src/EntKube.Web/EntKube.Web.csproj similarity index 63% rename from EntKube/EntKube/EntKube.csproj rename to src/EntKube.Web/EntKube.Web.csproj index dea9be6..7fe599b 100644 --- a/EntKube/EntKube/EntKube.csproj +++ b/src/EntKube.Web/EntKube.Web.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - aspnet-EntKube-fe5486ca-281a-42ed-b7f3-5e03de778549 + aspnet-EntKube_Web-e46010b0-5a46-448f-afbc-8bd8311cd127 true @@ -13,12 +13,16 @@ - - - - - - + + + + + + + + + + diff --git a/src/EntKube.Web/Program.cs b/src/EntKube.Web/Program.cs new file mode 100644 index 0000000..3b09f08 --- /dev/null +++ b/src/EntKube.Web/Program.cs @@ -0,0 +1,153 @@ +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using EntKube.Web.Client.Pages; +using EntKube.Web.Components; +using EntKube.Web.Components.Account; +using EntKube.Web.Data; + +namespace EntKube.Web; + +public class Program +{ + public static void Main(string[] args) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + // Register Blazor components with both server and WebAssembly interactivity. + // This BFF hosts the UI and proxies API calls to the backend microservices. + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents() + .AddAuthenticationStateSerialization(); + + builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Configure ASP.NET Identity authentication using cookie-based schemes. + + builder.Services.AddAuthentication(options => + { + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; + }) + .AddIdentityCookies(); + + // Register the database context. SQLite for local development. + + string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); + + builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + + builder.Services.AddDatabaseDeveloperPageExceptionFilter(); + + // Set up ASP.NET Identity with EF Core stores. + + builder.Services.AddIdentityCore(options => + { + options.SignIn.RequireConfirmedAccount = true; + options.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + builder.Services.AddSingleton, IdentityNoOpEmailSender>(); + + // Register HTTP clients for communicating with backend microservices. + // Each service has its own base URL configured via appsettings. + + builder.Services.AddHttpClient("ClustersApi", client => + { + client.BaseAddress = new Uri(builder.Configuration["Services:Clusters:BaseUrl"] ?? "https://localhost:5010"); + }); + + builder.Services.AddHttpClient("ProvisioningApi", client => + { + client.BaseAddress = new Uri(builder.Configuration["Services:Provisioning:BaseUrl"] ?? "https://localhost:5020"); + }); + + builder.Services.AddHttpClient("IdentityApi", client => + { + client.BaseAddress = new Uri(builder.Configuration["Services:Identity:BaseUrl"] ?? "https://localhost:5030"); + }); + + WebApplication app = builder.Build(); + + // Apply pending database migrations on startup with retry logic. + + MigrateDatabase(app); + + // Configure the HTTP request pipeline. + + if (app.Environment.IsDevelopment()) + { + app.UseWebAssemblyDebugging(); + app.UseMigrationsEndPoint(); + } + else + { + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); + } + + app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + app.UseHttpsRedirection(); + + app.UseAntiforgery(); + + app.MapStaticAssets(); + + app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(EntKube.Web.Client._Imports).Assembly); + + // Add additional endpoints required by the Identity /Account Razor components. + + app.MapAdditionalIdentityEndpoints(); + + app.Run(); + } + + /// + /// Applies any pending EF Core migrations when the application starts. + /// Retries with exponential backoff for containerized environments where + /// the database may not be immediately available. + /// + private static void MigrateDatabase(WebApplication app) + { + using IServiceScope scope = app.Services.CreateScope(); + ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); + ILogger logger = scope.ServiceProvider.GetRequiredService>(); + + int maxRetries = 5; + int delayMs = 1000; + + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + db.Database.Migrate(); + logger.LogInformation("Database migrations applied successfully on attempt {Attempt}.", attempt); + return; + } + catch (Exception ex) when (attempt < maxRetries) + { + logger.LogWarning( + ex, + "Database migration attempt {Attempt}/{MaxRetries} failed. Retrying in {DelayMs}ms...", + attempt, + maxRetries, + delayMs); + + Thread.Sleep(delayMs); + delayMs *= 2; + } + } + } +} diff --git a/EntKube/EntKube/Properties/launchSettings.json b/src/EntKube.Web/Properties/launchSettings.json similarity index 86% rename from EntKube/EntKube/Properties/launchSettings.json rename to src/EntKube.Web/Properties/launchSettings.json index 7413488..1499c34 100644 --- a/EntKube/EntKube/Properties/launchSettings.json +++ b/src/EntKube.Web/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5079", + "applicationUrl": "http://localhost:5086", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -16,7 +16,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7062;http://localhost:5079", + "applicationUrl": "https://localhost:7133;http://localhost:5086", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/EntKube.Web/appsettings.Development.json b/src/EntKube.Web/appsettings.Development.json new file mode 100644 index 0000000..2d6cd3d --- /dev/null +++ b/src/EntKube.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/EntKube/EntKube/appsettings.json b/src/EntKube.Web/appsettings.json similarity index 100% rename from EntKube/EntKube/appsettings.json rename to src/EntKube.Web/appsettings.json diff --git a/EntKube/EntKube/wwwroot/app.css b/src/EntKube.Web/wwwroot/app.css similarity index 100% rename from EntKube/EntKube/wwwroot/app.css rename to src/EntKube.Web/wwwroot/app.css diff --git a/EntKube/EntKube/wwwroot/favicon.png b/src/EntKube.Web/wwwroot/favicon.png similarity index 100% rename from EntKube/EntKube/wwwroot/favicon.png rename to src/EntKube.Web/wwwroot/favicon.png diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.js b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.js rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js diff --git a/EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map b/src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map similarity index 100% rename from EntKube/EntKube/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map rename to src/EntKube.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map diff --git a/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs b/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs new file mode 100644 index 0000000..fb3be37 --- /dev/null +++ b/tests/EntKube.Clusters.Tests/Domain/KubernetesClusterTests.cs @@ -0,0 +1,90 @@ +using EntKube.Clusters.Domain; +using FluentAssertions; + +namespace EntKube.Clusters.Tests.Domain; + +public class KubernetesClusterTests +{ + [Fact] + public void Register_WithValidInputs_CreatesClusterInPendingState() + { + // Arrange & Act — A tenant admin registers a new cluster with its name and API URL. + + KubernetesCluster cluster = KubernetesCluster.Register( + "production-eu", + "https://k8s.example.com:6443", + "secret-ref-123"); + + // Assert — The cluster should be created with a unique ID and start in Pending state + // because we haven't verified connectivity yet. + + cluster.Id.Should().NotBe(Guid.Empty); + cluster.Name.Should().Be("production-eu"); + cluster.ApiServerUrl.Should().Be("https://k8s.example.com:6443"); + cluster.KubeConfigSecret.Should().Be("secret-ref-123"); + cluster.Status.Should().Be(ClusterStatus.Pending); + cluster.RegisteredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Register_WithEmptyName_ThrowsArgumentException() + { + // Arrange & Act — Attempting to register a cluster without a name should fail + // because every cluster needs a human-readable identifier. + + Action act = () => KubernetesCluster.Register("", "https://k8s.example.com", null); + + // Assert + + act.Should().Throw() + .WithParameterName("name"); + } + + [Fact] + public void Register_WithEmptyApiServerUrl_ThrowsArgumentException() + { + // Arrange & Act — Without an API URL, we can't communicate with the cluster. + + Action act = () => KubernetesCluster.Register("my-cluster", "", null); + + // Assert + + act.Should().Throw() + .WithParameterName("apiServerUrl"); + } + + [Fact] + public void MarkConnected_SetsStatusToConnected() + { + // Arrange — A freshly registered cluster in Pending state. + + KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", null); + + // Act — The health check confirms the cluster is reachable. + + cluster.MarkConnected(); + + // Assert + + cluster.Status.Should().Be(ClusterStatus.Connected); + cluster.LastHealthCheckAt.Should().NotBeNull(); + } + + [Fact] + public void MarkUnreachable_SetsStatusToUnreachable() + { + // Arrange + + KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", null); + cluster.MarkConnected(); + + // Act — The health check fails. + + cluster.MarkUnreachable(); + + // Assert + + cluster.Status.Should().Be(ClusterStatus.Unreachable); + cluster.LastHealthCheckAt.Should().NotBeNull(); + } +} diff --git a/tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj b/tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj new file mode 100644 index 0000000..8a171f5 --- /dev/null +++ b/tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/EntKube.Clusters.Tests/Features/RegisterClusterHandlerTests.cs b/tests/EntKube.Clusters.Tests/Features/RegisterClusterHandlerTests.cs new file mode 100644 index 0000000..052cb3f --- /dev/null +++ b/tests/EntKube.Clusters.Tests/Features/RegisterClusterHandlerTests.cs @@ -0,0 +1,74 @@ +using EntKube.Clusters.Domain; +using EntKube.Clusters.Features.RegisterCluster; +using EntKube.Clusters.Infrastructure; +using EntKube.SharedKernel.Domain; +using FluentAssertions; + +namespace EntKube.Clusters.Tests.Features; + +public class RegisterClusterHandlerTests +{ + private readonly RegisterClusterHandler handler; + private readonly InMemoryClusterRepository repository; + + public RegisterClusterHandlerTests() + { + repository = new InMemoryClusterRepository(); + handler = new RegisterClusterHandler(repository); + } + + [Fact] + public async Task HandleAsync_WithValidRequest_ReturnsSuccessWithClusterId() + { + // Arrange — A valid registration request with all required fields. + + RegisterClusterRequest request = new("production-eu", "https://k8s.example.com:6443", "secret-ref"); + + // Act + + Result result = await handler.HandleAsync(request); + + // Assert — The handler should succeed and the cluster should be persisted. + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBe(Guid.Empty); + + KubernetesCluster? persisted = await repository.GetByIdAsync(result.Value!); + persisted.Should().NotBeNull(); + persisted!.Name.Should().Be("production-eu"); + } + + [Fact] + public async Task HandleAsync_WithEmptyName_ReturnsFailure() + { + // Arrange — Missing cluster name. + + RegisterClusterRequest request = new("", "https://k8s.example.com:6443", null); + + // Act + + Result result = await handler.HandleAsync(request); + + // Assert + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("name"); + } + + [Fact] + public async Task HandleAsync_WithEmptyApiUrl_ReturnsFailure() + { + // Arrange — Missing API server URL. + + RegisterClusterRequest request = new("my-cluster", "", null); + + // Act + + Result result = await handler.HandleAsync(request); + + // Assert + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("API server URL"); + } +} diff --git a/tests/EntKube.Identity.Tests/Domain/TenantTests.cs b/tests/EntKube.Identity.Tests/Domain/TenantTests.cs new file mode 100644 index 0000000..a9be29f --- /dev/null +++ b/tests/EntKube.Identity.Tests/Domain/TenantTests.cs @@ -0,0 +1,103 @@ +using EntKube.Identity.Domain; +using FluentAssertions; + +namespace EntKube.Identity.Tests.Domain; + +public class TenantTests +{ + [Fact] + public void Create_WithValidInputs_CreatesTenantWithAdminMember() + { + // Arrange & Act — An admin creates a new tenant organization. + + Guid userId = Guid.NewGuid(); + Tenant tenant = Tenant.Create("Acme Corp", "acme-corp", userId); + + // Assert — The tenant should be active and the creator should be admin. + + tenant.Id.Should().NotBe(Guid.Empty); + tenant.Name.Should().Be("Acme Corp"); + tenant.Slug.Should().Be("acme-corp"); + tenant.Status.Should().Be(TenantStatus.Active); + tenant.Members.Should().HaveCount(1); + tenant.Members[0].UserId.Should().Be(userId); + tenant.Members[0].Role.Should().Be(TenantRole.Admin); + } + + [Fact] + public void Create_WithEmptyName_ThrowsArgumentException() + { + // Arrange & Act + + Action act = () => Tenant.Create("", "slug", Guid.NewGuid()); + + // Assert + + act.Should().Throw() + .WithParameterName("name"); + } + + [Fact] + public void Create_WithEmptySlug_ThrowsArgumentException() + { + // Arrange & Act + + Action act = () => Tenant.Create("Acme", "", Guid.NewGuid()); + + // Assert + + act.Should().Throw() + .WithParameterName("slug"); + } + + [Fact] + public void AddMember_NewUser_AddsMemberToTenant() + { + // Arrange + + Tenant tenant = Tenant.Create("Acme", "acme", Guid.NewGuid()); + Guid newUserId = Guid.NewGuid(); + + // Act + + tenant.AddMember(newUserId, TenantRole.Member); + + // Assert + + tenant.Members.Should().HaveCount(2); + tenant.Members.Should().Contain(m => m.UserId == newUserId && m.Role == TenantRole.Member); + } + + [Fact] + public void AddMember_ExistingUser_DoesNotDuplicate() + { + // Arrange — Create a tenant (creator is already a member). + + Guid userId = Guid.NewGuid(); + Tenant tenant = Tenant.Create("Acme", "acme", userId); + + // Act — Try adding the same user again. + + tenant.AddMember(userId, TenantRole.Viewer); + + // Assert — Still only one member. + + tenant.Members.Should().HaveCount(1); + } + + [Fact] + public void Suspend_SetsStatusToSuspended() + { + // Arrange + + Tenant tenant = Tenant.Create("Acme", "acme", Guid.NewGuid()); + + // Act + + tenant.Suspend(); + + // Assert + + tenant.Status.Should().Be(TenantStatus.Suspended); + } +} diff --git a/tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj b/tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj new file mode 100644 index 0000000..4cb2cc1 --- /dev/null +++ b/tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/EntKube.Provisioning.Tests/Domain/ServiceInstanceTests.cs b/tests/EntKube.Provisioning.Tests/Domain/ServiceInstanceTests.cs new file mode 100644 index 0000000..6ef2a02 --- /dev/null +++ b/tests/EntKube.Provisioning.Tests/Domain/ServiceInstanceTests.cs @@ -0,0 +1,79 @@ +using EntKube.Provisioning.Domain; +using FluentAssertions; + +namespace EntKube.Provisioning.Tests.Domain; + +public class ServiceInstanceTests +{ + [Fact] + public void Provision_WithValidInputs_CreatesInstanceInPendingState() + { + // Arrange & Act — Request provisioning of a MinIO instance. + + Guid clusterId = Guid.NewGuid(); + + ServiceInstance instance = ServiceInstance.Provision( + clusterId, + ServiceType.MinIO, + "tenant-storage", + "minio-system"); + + // Assert — Starts pending until the reconciler deploys it. + + instance.Id.Should().NotBe(Guid.Empty); + instance.ClusterId.Should().Be(clusterId); + instance.ServiceType.Should().Be(ServiceType.MinIO); + instance.Name.Should().Be("tenant-storage"); + instance.Namespace.Should().Be("minio-system"); + instance.DesiredState.Should().Be(ServiceState.Running); + instance.CurrentState.Should().Be(ServiceState.Pending); + } + + [Fact] + public void Provision_WithEmptyName_ThrowsArgumentException() + { + // Arrange & Act + + Action act = () => ServiceInstance.Provision(Guid.NewGuid(), ServiceType.MinIO, "", "ns"); + + // Assert + + act.Should().Throw() + .WithParameterName("name"); + } + + [Fact] + public void MarkRunning_UpdatesCurrentState() + { + // Arrange + + ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.CloudNativePG, "db", "cnpg-system"); + + // Act + + instance.MarkRunning(); + + // Assert + + instance.CurrentState.Should().Be(ServiceState.Running); + instance.LastReconcileAt.Should().NotBeNull(); + } + + [Fact] + public void RequestDecommission_SetsDesiredStateToDecommissioned() + { + // Arrange + + ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.Keycloak, "auth", "keycloak-system"); + instance.MarkRunning(); + + // Act — Tenant admin decides to tear down this service. + + instance.RequestDecommission(); + + // Assert — Desired state changes but current state remains until reconciler acts. + + instance.DesiredState.Should().Be(ServiceState.Decommissioned); + instance.CurrentState.Should().Be(ServiceState.Running); + } +} diff --git a/tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj b/tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj new file mode 100644 index 0000000..cb6f54e --- /dev/null +++ b/tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj b/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj new file mode 100644 index 0000000..6c2a23d --- /dev/null +++ b/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file