too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -7,6 +7,7 @@
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["EntKube.Web.styles.css"]" />
<ImportMap />

View File

@@ -0,0 +1,135 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Clusters;
/// <summary>
/// BFF proxy endpoints for certificate management. Forwards requests from
/// the Blazor frontend to the Clusters microservice's certificate endpoints.
/// </summary>
public static class CertificateProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/clusters/{clusterId:guid}/certificates")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/clusters/{clusterId}/certificates/cas — list all CAs
group.MapGet("cas", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/certificates/cas", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/internal — create internal CA
group.MapPost("cas/internal", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/internal", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/domain — create domain CA
group.MapPost("cas/domain", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/domain", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// DELETE /api/clusters/{clusterId}/certificates/cas/{name} — delete a CA
group.MapDelete("cas/{name}", async (Guid clusterId, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{clusterId}/certificates/cas/{name}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/{name}/rotate — rotate a CA
group.MapPost("cas/{name}/rotate", async (Guid clusterId, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/{name}/rotate", null, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/{name}/publish — publish CA to trust bundle
group.MapPost("cas/{name}/publish", async (Guid clusterId, string name, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/{name}/publish", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{clusterId}/certificates/list — list all TLS certificates
group.MapGet("list", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/certificates/list", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/publish — publish a leaf cert to trust bundle
group.MapPost("publish", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/publish", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
}
}

View File

@@ -0,0 +1,381 @@
using EntKube.SharedKernel.Contracts;
using System.Net.Http.Json;
namespace EntKube.Web.Components.Clusters;
/// <summary>
/// BFF proxy endpoints for cluster management. The Blazor frontend calls these
/// endpoints, and the BFF forwards the requests to the Clusters microservice.
/// This keeps the frontend from needing to know about backend service URLs
/// and allows the BFF to attach auth tokens, apply rate limiting, etc.
/// </summary>
public static class ClustersProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/clusters — list all clusters (optionally filtered by tenantId and/or environmentId)
group.MapGet("", async (Guid? tenantId, Guid? environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
List<string> queryParams = new();
if (tenantId.HasValue)
{
queryParams.Add($"tenantId={tenantId.Value}");
}
if (environmentId.HasValue)
{
queryParams.Add($"environmentId={environmentId.Value}");
}
string url = queryParams.Count > 0
? $"/api/clusters?{string.Join("&", queryParams)}"
: "/api/clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id} — get a single cluster
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters — register a new cluster
group.MapPost("", async (RegisterClusterRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/clusters", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/clusters/{id} — remove a cluster
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.NoContent();
});
// POST /api/clusters/{id}/discover-prometheus — scan for Prometheus instances
group.MapPost("{id:guid}/discover-prometheus", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/discover-prometheus", null, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/prometheus — get discovered Prometheus endpoints
group.MapGet("{id:guid}/prometheus", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/prometheus", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{id}/install-prometheus — install kube-prometheus-stack
group.MapPost("{id:guid}/install-prometheus", async (Guid id, InstallPrometheusRequest? body, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/install-prometheus", body ?? new InstallPrometheusRequest(null), ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/health — get cluster health report from Prometheus
group.MapGet("{id:guid}/health", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/health", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{id}/check-connectivity — probe API server, update status
group.MapPost("{id:guid}/check-connectivity", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/check-connectivity", null, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/adopt — run adoption checks, return component report
group.MapPost("{id:guid}/adopt", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/adopt", null, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/components/deploy — deploy a component
group.MapPost("{id:guid}/components/deploy", async (Guid id, DeployComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/deploy", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/components/upgrade — upgrade an installed component to a new version
group.MapPost("{id:guid}/components/upgrade", async (Guid id, UpgradeComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/upgrade", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/components/{name}/versions — get available versions for a component
group.MapGet("components/{name}/versions", async (string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/components/{name}/versions", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/components/uninstall — uninstall a component
group.MapPost("{id:guid}/components/uninstall", async (Guid id, UninstallComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/uninstall", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/clusters/{id}/components/{name}/configure — configure a component
group.MapPut("{id:guid}/components/{name}/configure", async (Guid id, string name, ConfigureComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/clusters/{id}/components/{name}/configure", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/components/schemas — get configuration schemas for all components
group.MapGet("components/schemas", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync("/api/clusters/components/schemas", ct);
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/components/schemas — get schemas enriched with cluster context
group.MapGet("{id:guid}/components/schemas", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/components/schemas", ct);
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/services — list services for this cluster (from Provisioning)
group.MapGet("{id:guid}/services", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/services?clusterId={id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// PUT /api/clusters/{id}/provider — set or clear the cloud provider
group.MapPut("{id:guid}/provider", async (Guid id, SetProviderRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/clusters/{id}/provider", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/provider/test — test provider connection (with optional 2FA code)
group.MapPost("{id:guid}/provider/test", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
// Forward the request body (may contain TwoFactorCode for SMS 2FA).
StreamContent? content = null;
if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType is not null)
{
content = new StreamContent(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
}
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/provider/test", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/info — S3 storage availability info
group.MapGet("{id:guid}/storage/info", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/info", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/credentials — list S3 credentials
group.MapGet("{id:guid}/storage/credentials", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/credentials", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/storage/credentials — create new S3 credentials
group.MapPost("{id:guid}/storage/credentials", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/storage/credentials", new StringContent("", System.Text.Encoding.UTF8, "application/json"), ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/clusters/{id}/storage/credentials/{accessKey} — revoke S3 credentials
group.MapDelete("{id:guid}/storage/credentials/{accessKey}", async (Guid id, string accessKey, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}/storage/credentials/{accessKey}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/buckets — list storage buckets
group.MapGet("{id:guid}/storage/buckets", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/buckets", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/storage/buckets — create a new bucket
group.MapPost("{id:guid}/storage/buckets", async (Guid id, HttpRequest httpRequest, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
string requestBody = await new StreamReader(httpRequest.Body).ReadToEndAsync(ct);
HttpResponseMessage response = await client.PostAsync(
$"/api/clusters/{id}/storage/buckets",
new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"), ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/buckets/{bucketId} — get bucket details
group.MapGet("{id:guid}/storage/buckets/{bucketId:guid}", async (Guid id, Guid bucketId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/buckets/{bucketId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/clusters/{id}/storage/buckets/{bucketId} — delete a bucket
group.MapDelete("{id:guid}/storage/buckets/{bucketId:guid}", async (Guid id, Guid bucketId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}/storage/buckets/{bucketId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/settings — read cluster settings from live cluster
group.MapGet("{id:guid}/settings", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/settings", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/clusters/{id}/settings — update cluster settings on live cluster
group.MapPut("{id:guid}/settings", async (Guid id, HttpRequest httpRequest, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
string requestBody = await new StreamReader(httpRequest.Body).ReadToEndAsync(ct);
HttpResponseMessage response = await client.PutAsync(
$"/api/clusters/{id}/settings",
new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"), ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
public record RegisterClusterRequest(string Name, string ApiServerUrl, string KubeConfig, Guid TenantId, string ContextName, Guid EnvironmentId);
public record SetProviderRequest(
string Provider,
string? Username,
string? Password,
string? Region,
string? OpenStackAuthUrl = null,
string? OpenStackProjectId = null,
string? OpenStackUsername = null,
string? OpenStackPassword = null,
string? OpenStackUserDomainName = null);
public record InstallPrometheusRequest(string? Namespace);
public record DeployComponentRequest(string ComponentName, string? Version = null, string? Namespace = null, Dictionary<string, string>? Parameters = null);
public record UninstallComponentRequest(string ComponentName, string? Namespace = null, Dictionary<string, string>? Parameters = null);
public record UpgradeComponentRequest(string ComponentName, string Version);
public record ConfigureComponentRequest(string? Namespace = null, Dictionary<string, string>? Values = null);

View File

@@ -0,0 +1,132 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Identity;
/// <summary>
/// BFF proxy endpoints for tenant management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Identity microservice.
///
/// Every handler returns JSON — even on failure — because Blazor WASM clients
/// deserialize responses with GetFromJsonAsync. If we let exceptions propagate
/// or return bare status codes, UseStatusCodePagesWithReExecute kicks in and
/// serves HTML, which causes "'<' is an invalid start of a value" errors.
/// </summary>
public static class TenantsProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/tenants")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/tenants — list all tenants
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, "/api/tenants", ct);
});
// GET /api/tenants/{id} — get a single tenant
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{id}", ct);
});
// POST /api/tenants — create a new tenant
group.MapPost("", async (CreateTenantRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, "/api/tenants", request, ct);
});
// ─── Environment endpoints ───────────────────────────────────────
// GET /api/tenants/{tenantId}/environments — list environments for a tenant
group.MapGet("{tenantId:guid}/environments", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{tenantId}/environments", ct);
});
// POST /api/tenants/{tenantId}/environments — create an environment
group.MapPost("{tenantId:guid}/environments", async (Guid tenantId, CreateEnvironmentProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/tenants/{tenantId}/environments", request, ct);
});
// ─── Customer endpoints ──────────────────────────────────────────
// GET /api/tenants/{tenantId}/customers — list customers for a tenant
group.MapGet("{tenantId:guid}/customers", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{tenantId}/customers", ct);
});
// POST /api/tenants/{tenantId}/customers — create a customer
group.MapPost("{tenantId:guid}/customers", async (Guid tenantId, CreateCustomerProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/tenants/{tenantId}/customers", request, ct);
});
}
// ─── Shared proxy helpers ─────────────────────────────────────────────
//
// These catch HttpRequestException (backend unreachable) and non-success
// responses, always returning a JSON ApiError so Blazor clients never
// receive HTML error pages.
private static async Task<IResult> ProxyGetAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("IdentityApi");
HttpResponseMessage response = await client.GetAsync(path, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Identity service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Identity service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPostAsync<T>(
IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("IdentityApi");
HttpResponseMessage response = await client.PostAsJsonAsync(path, payload, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Identity service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Identity service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private record ApiError(bool Success, string Error);
}
public record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId);
public record CreateEnvironmentProxyRequest(string Name, string Slug);
public record CreateCustomerProxyRequest(string Name, string Slug);

View File

@@ -0,0 +1,381 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for app management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
/// Apps represent customer workloads (Deployments or Helm charts) that can
/// be deployed across multiple environments.
/// </summary>
public static class AppsProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/apps")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/apps/by-customer/{customerId} — list all apps for a customer.
group.MapGet("by-customer/{customerId:guid}", async (
Guid customerId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/customers/{customerId}/apps", ct);
});
// GET /api/apps/{appId} — get full app detail with environments.
group.MapGet("{appId:guid}", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/apps/{appId}", ct);
});
// POST /api/apps/by-customer/{customerId} — create a new app.
group.MapPost("by-customer/{customerId:guid}", async (
Guid customerId,
CreateAppProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/customers/{customerId}/apps", request, ct);
});
// POST /api/apps/{appId}/environments — add app to an environment.
group.MapPost("{appId:guid}/environments", async (
Guid appId,
AddEnvironmentProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/apps/{appId}/environments", request, ct);
});
// PUT /api/apps/{appId}/environments/{environmentId}/deployment — configure deployment spec.
group.MapPut("{appId:guid}/environments/{environmentId:guid}/deployment", async (
Guid appId,
Guid environmentId,
DeploymentSpecProxy spec,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPutAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/deployment", spec, ct);
});
// PUT /api/apps/{appId}/environments/{environmentId}/helm — configure helm release spec.
group.MapPut("{appId:guid}/environments/{environmentId:guid}/helm", async (
Guid appId,
Guid environmentId,
HelmReleaseSpecProxy spec,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPutAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/helm", spec, ct);
});
// POST /api/apps/{appId}/environments/{environmentId}/secrets — add a secret reference.
group.MapPost("{appId:guid}/environments/{environmentId:guid}/secrets", async (
Guid appId,
Guid environmentId,
AddSecretProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/secrets", request, ct);
});
// DELETE /api/apps/{appId} — delete an app and all its environments.
group.MapDelete("{appId:guid}", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}", ct);
});
// DELETE /api/apps/{appId}/environments/{environmentId} — remove an environment from an app.
group.MapDelete("{appId:guid}/environments/{environmentId:guid}", async (
Guid appId,
Guid environmentId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}", ct);
});
// DELETE /api/apps/{appId}/environments/{environmentId}/secrets/{secretName} — remove a secret.
group.MapDelete("{appId:guid}/environments/{environmentId:guid}/secrets/{secretName}", async (
Guid appId,
Guid environmentId,
string secretName,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/secrets/{secretName}", ct);
});
// POST /api/apps/{appId}/suspend — suspend an app.
group.MapPost("{appId:guid}/suspend", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/suspend", ct);
});
// POST /api/apps/{appId}/activate — re-activate a suspended app.
group.MapPost("{appId:guid}/activate", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/activate", ct);
});
// POST /api/apps/{appId}/environments/{environmentId}/sync — trigger immediate sync.
group.MapPost("{appId:guid}/environments/{environmentId:guid}/sync", async (
Guid appId,
Guid environmentId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/sync", ct);
});
// ─── Cluster Resource Lookups ────────────────────────────────────
// These proxy to the Provisioning service which talks to the cluster.
// GET /api/apps/clusters/{clusterId}/namespaces/{ns}/service-accounts — list service accounts.
group.MapGet("clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async (
Guid clusterId,
string ns,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/service-accounts", ct);
});
// GET /api/apps/clusters/{clusterId}/namespaces/{ns}/pvcs — list PVCs.
group.MapGet("clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async (
Guid clusterId,
string ns,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/pvcs", ct);
});
// POST /api/apps/clusters/{clusterId}/namespaces/{ns}/service-accounts — create a service account.
group.MapPost("clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async (
Guid clusterId,
string ns,
CreateResourceRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/service-accounts", request, ct);
});
// POST /api/apps/clusters/{clusterId}/namespaces/{ns}/pvcs — create a PVC.
group.MapPost("clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async (
Guid clusterId,
string ns,
CreatePvcRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/pvcs", request, ct);
});
}
// ─── Shared proxy helpers ─────────────────────────────────────────────
private static async Task<IResult> ProxyGetAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync(path, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPostAsync<T>(
IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync(path, payload, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPostEmptyAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsync(path, null, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPutAsync<T>(
IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync(path, payload, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyDeleteAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync(path, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private record ApiError(bool Success, string Error);
}
// ─── Proxy DTOs ──────────────────────────────────────────────────────────
public record CreateAppProxyRequest(Guid TenantId, string Name, string Slug, string Type);
public record AddEnvironmentProxyRequest(Guid EnvironmentId, Guid ClusterId, string Namespace);
public record AddSecretProxyRequest(string Name, string VaultKey);
public record DeploymentSpecProxy(
string Image,
string Tag,
int Replicas,
int ContainerPort,
int ServicePort,
string? HostName,
string? PathPrefix,
Dictionary<string, string>? EnvironmentVariables,
ResourceSpecProxy? Resources);
public record ResourceSpecProxy(
string? CpuRequest,
string? CpuLimit,
string? MemoryRequest,
string? MemoryLimit);
public record HelmReleaseSpecProxy(
string RepoUrl,
string ChartName,
string ChartVersion,
string? ValuesYaml);
public record CreateResourceRequest(string Name);
public record CreatePvcRequest(
string Name,
string StorageSize,
string AccessMode = "ReadWriteOnce",
string? StorageClass = null);

View File

@@ -0,0 +1,31 @@
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Gitea operations. The Blazor frontend calls these
/// to fetch Gitea instance info and runner statuses. Requests are forwarded
/// to the Clusters microservice which has direct K8s API access.
/// </summary>
public static class GiteaProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/gitea")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/gitea/{clusterId}/info — Gitea instance info + runners.
group.MapGet("{clusterId:guid}/info", async (
Guid clusterId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync(
$"/api/clusters/{clusterId}/gitea/info", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Content(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}

View File

@@ -0,0 +1,153 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Grafana instance management. Forwards requests from
/// the Blazor frontend to the Provisioning microservice's Grafana endpoints.
/// </summary>
public static class GrafanaProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/grafana")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/grafana — list all Grafana instances
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync("/api/grafana", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/grafana/environment/{environmentId}
group.MapGet("environment/{environmentId:guid}", async (Guid environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/grafana/environment/{environmentId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/grafana/{id}
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/grafana/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/grafana — create a new Grafana instance
group.MapPost("", async (CreateGrafanaInstanceProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/grafana", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/grafana/{id}/persistence
group.MapPut("{id:guid}/persistence", async (Guid id, ConfigurePersistenceProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/persistence", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/grafana/{id}/oidc
group.MapPut("{id:guid}/oidc", async (Guid id, ConfigureOidcProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/oidc", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}/oidc
group.MapDelete("{id:guid}/oidc", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/oidc", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/grafana/{id}/ingress
group.MapPut("{id:guid}/ingress", async (Guid id, ConfigureGrafanaIngressProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/ingress", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}/ingress
group.MapDelete("{id:guid}/ingress", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/ingress", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/grafana/{id}/dashboards
group.MapGet("{id:guid}/dashboards", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/grafana/{id}/dashboards", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/grafana/{id}/dashboards
group.MapPost("{id:guid}/dashboards", async (Guid id, AddDashboardProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/grafana/{id}/dashboards", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}/dashboards/{name}
group.MapDelete("{id:guid}/dashboards/{name}", async (Guid id, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/dashboards/{name}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
// ─── Proxy Request DTOs ─────────────────────────────────────────────
public record CreateGrafanaInstanceProxyRequest(Guid EnvironmentId, Guid ClusterId, string Name, string Namespace);
public record ConfigurePersistenceProxyRequest(bool Enabled, string? Size);
public record ConfigureOidcProxyRequest(
string Provider, string ClientId, string ClientSecretRef,
string AuthUrl, string TokenUrl, string ApiUrl,
string Scopes, string? RoleAttributePath, bool AutoLogin);
public record ConfigureGrafanaIngressProxyRequest(
bool Enabled, string Provider,
string? Hostname, bool TlsEnabled,
string TlsCertificateMode = "Manual",
string? TlsSecretName = null, string? ClusterIssuer = null);
public record AddDashboardProxyRequest(string Name, string DisplayName, string Category, string JsonContent);

View File

@@ -0,0 +1,54 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Harbor operations. The Blazor frontend calls these
/// endpoints to list Harbor projects and create pull secrets. Requests are
/// forwarded to the Clusters microservice which has direct Harbor API access.
/// </summary>
public static class HarborProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/harbor")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/harbor/{clusterId}/projects — list Harbor projects on a cluster.
group.MapGet("{clusterId:guid}/projects", async (
Guid clusterId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync(
$"/api/clusters/{clusterId}/harbor/projects", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Content(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/harbor/{clusterId}/pull-secret — create a Harbor pull secret.
group.MapPost("{clusterId:guid}/pull-secret", async (
Guid clusterId,
CreateHarborPullSecretProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/clusters/{clusterId}/harbor/pull-secret", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Content(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
public record CreateHarborPullSecretProxyRequest(
string ProjectName,
string AppSlug,
string TargetNamespace);

View File

@@ -0,0 +1,135 @@
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Keycloak realm management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
/// </summary>
public static class KeycloakRealmProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/keycloak-realms")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/keycloak-realms/cluster/{clusterId} — list realms for a cluster.
group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/keycloak-realms/cluster/{clusterId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/keycloak-realms/{id} — get a single realm.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/keycloak-realms/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/keycloak-realms — create a new realm.
group.MapPost("", async (HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PostAsync("/api/keycloak-realms", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/keycloak-realms/{id} — delete a realm.
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/keycloak-realms/{id}/branding — update realm branding.
group.MapPut("{id:guid}/branding", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PutAsync($"/api/keycloak-realms/{id}/branding", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/keycloak-realms/{id}/pages — update realm pages config.
group.MapPut("{id:guid}/pages", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PutAsync($"/api/keycloak-realms/{id}/pages", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/keycloak-realms/{id}/identity-providers — add identity provider.
group.MapPost("{id:guid}/identity-providers", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PostAsync($"/api/keycloak-realms/{id}/identity-providers", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/keycloak-realms/{id}/identity-providers/{alias} — remove identity provider.
group.MapDelete("{id:guid}/identity-providers/{alias}", async (Guid id, string alias, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}/identity-providers/{alias}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/keycloak-realms/{id}/organizations — add organization.
group.MapPost("{id:guid}/organizations", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PostAsync($"/api/keycloak-realms/{id}/organizations", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/keycloak-realms/{id}/organizations/{name} — remove organization.
group.MapDelete("{id:guid}/organizations/{name}", async (Guid id, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}/organizations/{name}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}

View File

@@ -0,0 +1,339 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for MinIO tenant management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
///
/// The BFF enriches requests with the cluster's kubeconfig (fetched from the Clusters
/// service) so the frontend never needs to handle sensitive credentials.
/// </summary>
public static class MinioTenantProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/minio-tenants")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/minio-tenants — list all MinIO tenants.
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync("/api/minio-tenants", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/minio-tenants/cluster/{clusterId} — list tenants for a specific cluster.
group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/minio-tenants/cluster/{clusterId}", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/minio-tenants/{id} — get a specific tenant.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/minio-tenants/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/minio-tenants — create a new MinIO tenant.
// The BFF fetches the kubeconfig from the Clusters service and injects it.
group.MapPost("", async (CreateMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
// Fetch cluster credentials from the Clusters service.
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct);
if (creds is null)
{
return Results.BadRequest(new { error = "Could not fetch cluster credentials." });
}
// Forward to Provisioning with credentials injected.
object provisioningRequest = new
{
request.ClusterId,
creds.KubeConfig,
creds.ContextName,
request.Name,
request.Namespace,
request.Pools
};
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/minio-tenants", provisioningRequest, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// PUT /api/minio-tenants/{id}/configure — update tenant pool configuration.
group.MapPut("{id:guid}/configure", async (Guid id, ConfigureMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
// Fetch cluster credentials.
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct);
if (creds is null)
{
return Results.BadRequest(new { error = "Could not fetch cluster credentials." });
}
object provisioningRequest = new
{
creds.KubeConfig,
creds.ContextName,
request.Pools
};
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/minio-tenants/{id}/configure", provisioningRequest, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// DELETE /api/minio-tenants/{id} — delete a tenant from the cluster.
group.MapDelete("{id:guid}", async (Guid id, [FromBody] DeleteMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
// Fetch cluster credentials.
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct);
if (creds is null)
{
return Results.BadRequest(new { error = "Could not fetch cluster credentials." });
}
object provisioningRequest = new
{
creds.KubeConfig,
creds.ContextName
};
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpRequestMessage deleteRequest = new(HttpMethod.Delete, $"/api/minio-tenants/{id}")
{
Content = JsonContent.Create(provisioningRequest)
};
HttpResponseMessage response = await client.SendAsync(deleteRequest, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/minio-tenants/adopt — discover and adopt existing MinIO tenants.
// The frontend sends clusterId, the BFF injects kubeConfig/contextName.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/minio-tenants/adopt", "POST", ct);
});
// POST /api/minio-tenants/{id}/health — live health from K8s.
// BFF resolves credentials and forwards via POST (kubeConfig is too large for query strings).
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/minio-tenants/{id}/health", ct);
});
// GET /api/minio-tenants/{id}/buckets — list buckets (forwarded as POST).
group.MapGet("{id:guid}/buckets", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/minio-tenants/{id}/buckets", ct);
});
// POST /api/minio-tenants/{id}/buckets/create — create a bucket.
group.MapPost("{id:guid}/buckets/create", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/minio-tenants/{id}/buckets/create", "POST", ct);
});
// POST /api/minio-tenants/{id}/buckets/delete — delete a bucket.
group.MapPost("{id:guid}/buckets/delete", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/minio-tenants/{id}/buckets/delete", "POST", ct);
});
}
/// <summary>
/// Fetches the kubeconfig and context name for a cluster from the Clusters service.
/// This is an internal BFF-to-service call — credentials never reach the frontend.
/// </summary>
private static async Task<ClusterCredentialsResponse?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
ApiResponseWrapper<ClusterCredentialsResponse>? result =
await credsResponse.Content.ReadFromJsonAsync<ApiResponseWrapper<ClusterCredentialsResponse>>(ct);
return result?.Data;
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Resolves kubeConfig for a cluster and forwards a GET-style request as a POST
/// with credentials in the body. Used for read operations where the kubeConfig
/// payload is too large for query strings.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct)
{
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
}
// ─── Frontend DTOs (no kubeconfig — BFF injects it) ─────────────────────────
public record CreateMinioTenantFrontendRequest(
Guid ClusterId,
string Name,
string Namespace,
List<PoolSpecProxy> Pools);
public record ConfigureMinioTenantFrontendRequest(
Guid ClusterId,
List<PoolSpecProxy> Pools);
public record DeleteMinioTenantFrontendRequest(
Guid ClusterId);
public record PoolSpecProxy(
int Servers,
int VolumesPerServer,
string StorageSize,
string StorageClass,
string? CpuRequest = null,
string? CpuLimit = null,
string? MemoryRequest = null,
string? MemoryLimit = null);
// ─── Internal response types ─────────────────────────────────────────────────
internal record ClusterCredentialsResponse(string KubeConfig, string ContextName);
internal record ApiResponseWrapper<T>
{
public bool Success { get; init; }
public T? Data { get; init; }
public string? Error { get; init; }
}

View File

@@ -0,0 +1,329 @@
using System.Text;
using System.Text.Json.Nodes;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for MongoDB cluster management. The Blazor frontend calls
/// these endpoints, and the BFF forwards requests to the Provisioning
/// microservice's /api/mongo-clusters routes.
///
/// The frontend never sends kubeConfig — the BFF resolves it server-side by
/// calling the Clusters service's /api/clusters/{id}/credentials endpoint,
/// then injects it into the request before forwarding to Provisioning.
/// </summary>
public static class MongoClusterProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/mongo-clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/mongo-clusters — list clusters, optionally filtered by clusterId.
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/mongo-clusters?clusterId={clusterId.Value}"
: "/api/mongo-clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/mongo-clusters/{id} — get a single cluster.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/mongo-clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/mongo-clusters — create a new MongoDB cluster.
// BFF enriches with bucket credentials so Provisioning can create the K8s
// secret directly without depending on a pre-existing copy in cnpg-system.
group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/mongo-clusters", "POST", ct,
enrichBody: async (body, factory, cancellation) =>
{
await EnrichWithBucketCredentials(body, factory, cancellation);
});
});
// POST /api/mongo-clusters/adopt — discover and adopt existing clusters.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/mongo-clusters/adopt", "POST", ct);
});
// POST /api/mongo-clusters/{id}/upgrade — initiate version upgrade.
group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/upgrade", "POST", ct);
});
// PUT /api/mongo-clusters/{id}/configure — reconfigure settings.
group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/configure", "PUT", ct);
});
// GET /api/mongo-clusters/{id}/health — live health from K8s.
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/mongo-clusters/{id}/health", ct);
});
// POST /api/mongo-clusters/{id}/restart — rolling restart.
group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/restart", "POST", ct);
});
// POST /api/mongo-clusters/{id}/stepdown — primary stepdown.
group.MapPost("{id:guid}/stepdown", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/stepdown", "POST", ct);
});
// DELETE /api/mongo-clusters/{id} — delete a MongoDB cluster.
group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/mongo-clusters/{id}", ct, "DELETE");
});
// DELETE /api/mongo-clusters/{id}/databases/{dbName} — drop a database.
group.MapDelete("{id:guid}/databases/{dbName}", async (Guid id, string dbName, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/mongo-clusters/{id}/databases/{dbName}", ct, "DELETE");
});
// GET /api/mongo-clusters/available-versions — list supported MongoDB versions.
group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, "/api/mongo-clusters/available-versions", ct);
});
}
/// <summary>
/// Forwards a request to Provisioning by injecting kubeConfig+contextName into
/// the body as a POST. This avoids putting large kubeConfig data into query
/// strings, which can exceed URL length limits.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct, string httpMethod = "POST")
{
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "DELETE"
? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// An optional enrichBody callback can modify the body before forwarding.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct,
Func<JsonNode, IHttpClientFactory, CancellationToken, Task>? enrichBody = null)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
if (enrichBody is not null)
{
await enrichBody(body, httpClientFactory, ct);
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
private static async Task<ClusterCreds?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
JsonNode? credsBody = await JsonNode.ParseAsync(
await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue<string>();
string? contextName = credsBody?["data"]?["contextName"]?.GetValue<string>();
if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName))
{
return null;
}
return new ClusterCreds(kubeConfig, contextName);
}
private record ClusterCreds(string KubeConfig, string ContextName);
/// <summary>
/// Fetches bucket credentials from the Clusters service and injects them into the
/// request body so the Provisioning service can create the secret directly.
/// </summary>
private static async Task EnrichWithBucketCredentials(
JsonNode body, IHttpClientFactory httpClientFactory, CancellationToken ct)
{
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
string? endpoint = body["minioEndpoint"]?.GetValue<string>();
if (clusterId is null || string.IsNullOrWhiteSpace(endpoint))
{
return;
}
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage bucketsResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets", ct);
if (!bucketsResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? bucketsBody = await JsonNode.ParseAsync(
await bucketsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
JsonArray? buckets = bucketsBody?["data"]?.AsArray();
if (buckets is null)
{
return;
}
foreach (JsonNode? bucket in buckets)
{
if (bucket is null)
{
continue;
}
string? bucketEndpoint = bucket["endpoint"]?.GetValue<string>();
if (string.Equals(bucketEndpoint, endpoint, StringComparison.OrdinalIgnoreCase))
{
Guid bucketId = bucket["id"]!.GetValue<Guid>();
HttpResponseMessage detailResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets/{bucketId}", ct);
if (!detailResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? detailBody = await JsonNode.ParseAsync(
await detailResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? accessKey = detailBody?["data"]?["accessKey"]?.GetValue<string>();
string? secretKey = detailBody?["data"]?["secretKey"]?.GetValue<string>();
string? region = detailBody?["data"]?["region"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey))
{
body["s3AccessKey"] = accessKey;
body["s3SecretKey"] = secretKey;
if (!string.IsNullOrWhiteSpace(region))
{
body["s3Region"] = region;
}
}
return;
}
}
}
}

View File

@@ -0,0 +1,398 @@
using System.Text;
using System.Text.Json.Nodes;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for PostgreSQL cluster management. The Blazor frontend
/// calls these endpoints, and the BFF forwards requests to the Provisioning
/// microservice's /api/postgres-clusters routes.
///
/// The frontend never sends kubeConfig — the BFF resolves it server-side by
/// calling the Clusters service's /api/clusters/{id}/credentials endpoint,
/// then injects it into the request before forwarding to Provisioning.
/// </summary>
public static class PostgresClusterProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/postgres-clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/postgres-clusters — list clusters, optionally filtered by clusterId.
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/postgres-clusters?clusterId={clusterId.Value}"
: "/api/postgres-clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/postgres-clusters/{id} — get a single cluster.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/postgres-clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/postgres-clusters — create a new PG cluster.
// Frontend sends clusterId + config. BFF injects kubeConfig/contextName
// and bucket credentials so the Provisioning service can create the K8s
// secret directly without depending on a pre-existing copy in cnpg-system.
group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/postgres-clusters", "POST", ct,
enrichBody: async (body, factory, cancellation) =>
{
await EnrichWithBucketCredentials(body, factory, cancellation);
});
});
// POST /api/postgres-clusters/adopt — discover and adopt existing clusters.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/postgres-clusters/adopt", "POST", ct);
});
// POST /api/postgres-clusters/{id}/databases — add a database.
group.MapPost("{id:guid}/databases", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/databases", "POST", ct);
});
// POST /api/postgres-clusters/{id}/upgrade — initiate version upgrade.
group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/upgrade", "POST", ct);
});
// PUT /api/postgres-clusters/{id}/configure — reconfigure settings.
group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/configure", "PUT", ct);
});
// GET /api/postgres-clusters/{id}/health — live health from K8s.
// The BFF resolves credentials server-side and forwards to Provisioning
// via POST (kubeConfig is too large for query strings).
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/health", ct);
});
// GET /api/postgres-clusters/{id}/backups — list backups.
group.MapGet("{id:guid}/backups", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/backups", ct);
});
// GET /api/postgres-clusters/{id}/backups/diagnose — raw backup diagnostics.
group.MapGet("{id:guid}/backups/diagnose", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/backups/diagnose", ct);
});
// POST /api/postgres-clusters/{id}/backup — trigger on-demand backup.
group.MapPost("{id:guid}/backup", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/backup", "POST", ct);
});
// POST /api/postgres-clusters/{id}/restart — rolling restart.
group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restart", "POST", ct);
});
// POST /api/postgres-clusters/{id}/failover — controlled switchover.
group.MapPost("{id:guid}/failover", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/failover", "POST", ct);
});
// DELETE /api/postgres-clusters/{id}/databases/{dbName} — drop a database.
group.MapDelete("{id:guid}/databases/{dbName}", async (Guid id, string dbName, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/databases/{dbName}", ct, "DELETE");
});
// DELETE /api/postgres-clusters/{id} — delete the entire cluster.
group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}", ct, "DELETE");
});
// POST /api/postgres-clusters/{id}/restore — restore from backup.
group.MapPost("{id:guid}/restore", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restore", "POST", ct);
});
// POST /api/postgres-clusters/{id}/restore/preview — dry-run restore preview.
group.MapPost("{id:guid}/restore/preview", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restore/preview", "POST", ct);
});
// GET /api/postgres-clusters/available-versions — list supported PG versions.
group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, "/api/postgres-clusters/available-versions", ct);
});
// GET /api/postgres-clusters/{id}/databases/live — live database listing.
group.MapGet("{id:guid}/databases/live", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/databases/live", ct);
});
}
/// <summary>
/// Forwards a request to Provisioning by injecting kubeConfig+contextName into
/// the body as a POST. This avoids putting large kubeConfig data into query
/// strings, which can exceed URL length limits.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct, string httpMethod = "POST")
{
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "DELETE"
? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// An optional enrichBody callback can modify the body before forwarding.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct,
Func<JsonNode, IHttpClientFactory, CancellationToken, Task>? enrichBody = null)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
// Resolve kubeConfig from the Clusters service.
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
// Allow callers to enrich the body with additional data before forwarding.
if (enrichBody is not null)
{
await enrichBody(body, httpClientFactory, ct);
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Fetches kubeConfig and contextName from the Clusters service for a given
/// Kubernetes cluster ID. This is a server-to-server call — the frontend
/// never sees the kubeConfig.
/// </summary>
private static async Task<ClusterCreds?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
JsonNode? credsBody = await JsonNode.ParseAsync(
await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue<string>();
string? contextName = credsBody?["data"]?["contextName"]?.GetValue<string>();
if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName))
{
return null;
}
return new ClusterCreds(kubeConfig, contextName);
}
private record ClusterCreds(string KubeConfig, string ContextName);
/// <summary>
/// When the frontend selected a storage bucket for backups, fetch the bucket's
/// access key and secret key from the Clusters service and inject them into the
/// request body. This allows the Provisioning service to create the K8s secret
/// directly in the target namespace without depending on a copy in cnpg-system.
/// </summary>
private static async Task EnrichWithBucketCredentials(
JsonNode body, IHttpClientFactory httpClientFactory, CancellationToken ct)
{
// The frontend sends minioCredentialsSecret = "cnpg-backup-creds" and
// clusterId. We need to find which bucket was selected to get its credentials.
// The frontend doesn't send bucketId to the provisioning path, but we can
// look up the bucket by matching the endpoint that was sent.
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
string? endpoint = body["minioEndpoint"]?.GetValue<string>();
if (clusterId is null || string.IsNullOrWhiteSpace(endpoint))
{
return;
}
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage bucketsResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets", ct);
if (!bucketsResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? bucketsBody = await JsonNode.ParseAsync(
await bucketsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
JsonArray? buckets = bucketsBody?["data"]?.AsArray();
if (buckets is null)
{
return;
}
// Find the bucket that matches the endpoint the frontend sent.
foreach (JsonNode? bucket in buckets)
{
if (bucket is null)
{
continue;
}
string? bucketEndpoint = bucket["endpoint"]?.GetValue<string>();
if (string.Equals(bucketEndpoint, endpoint, StringComparison.OrdinalIgnoreCase))
{
// Found the matching bucket — get its full details (including secrets).
Guid bucketId = bucket["id"]!.GetValue<Guid>();
HttpResponseMessage detailResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets/{bucketId}", ct);
if (!detailResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? detailBody = await JsonNode.ParseAsync(
await detailResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? accessKey = detailBody?["data"]?["accessKey"]?.GetValue<string>();
string? secretKey = detailBody?["data"]?["secretKey"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey))
{
body["s3AccessKey"] = accessKey;
body["s3SecretKey"] = secretKey;
}
return;
}
}
}
}

View File

@@ -0,0 +1,121 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Prometheus stack management. Forwards requests from
/// the Blazor frontend to the Provisioning microservice's Prometheus endpoints.
/// </summary>
public static class PrometheusProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/prometheus")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/prometheus — list all Prometheus stacks
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync("/api/prometheus", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/prometheus/cluster/{clusterId}
group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/prometheus/cluster/{clusterId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/prometheus/environment/{environmentId}
group.MapGet("environment/{environmentId:guid}", async (Guid environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/prometheus/environment/{environmentId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/prometheus/{id}
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/prometheus/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/prometheus — create a new Prometheus stack
group.MapPost("", async (CreatePrometheusStackProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/prometheus", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/prometheus/{id}
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/prometheus/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/prometheus/{id}/config
group.MapPut("{id:guid}/config", async (Guid id, ConfigurePrometheusProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/config", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/prometheus/{id}/alertmanager
group.MapPut("{id:guid}/alertmanager", async (Guid id, ConfigureAlertmanagerProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/alertmanager", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/prometheus/{id}/ingress
group.MapPut("{id:guid}/ingress", async (Guid id, ConfigureIngressProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/ingress", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/prometheus/{id}/ingress
group.MapDelete("{id:guid}/ingress", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/prometheus/{id}/ingress", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
// ─── Proxy Request DTOs ─────────────────────────────────────────────
public record CreatePrometheusStackProxyRequest(Guid ClusterId, Guid EnvironmentId, string Name, string Namespace);
public record ConfigurePrometheusProxyRequest(string? Retention, int? Replicas, string? StorageSize, string? RetentionSize);
public record ConfigureAlertmanagerProxyRequest(bool? Enabled, int? Replicas);
public record ConfigureIngressProxyRequest(
bool Enabled, string Provider,
string? PrometheusHostname, string? AlertmanagerHostname,
bool TlsEnabled, string TlsCertificateMode = "Manual",
string? TlsSecretName = null, string? ClusterIssuer = null);

View File

@@ -0,0 +1,241 @@
using System.Text;
using System.Text.Json.Nodes;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Redis cluster management. The Blazor frontend calls
/// these endpoints, and the BFF forwards requests to the Provisioning
/// microservice's /api/redis-clusters routes.
///
/// The frontend never sends kubeConfig — the BFF resolves it server-side by
/// calling the Clusters service's /api/clusters/{id}/credentials endpoint,
/// then injects it into the request before forwarding to Provisioning.
/// </summary>
public static class RedisClusterProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/redis-clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/redis-clusters — list clusters, optionally filtered by clusterId.
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/redis-clusters?clusterId={clusterId.Value}"
: "/api/redis-clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/redis-clusters/{id} — get a single cluster.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/redis-clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/redis-clusters — create a new Redis instance.
// Frontend sends clusterId + config. BFF injects kubeConfig/contextName.
group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/redis-clusters", "POST", ct);
});
// POST /api/redis-clusters/adopt — discover and adopt existing instances.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/redis-clusters/adopt", "POST", ct);
});
// POST /api/redis-clusters/{id}/upgrade — initiate version upgrade.
group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/upgrade", "POST", ct);
});
// PUT /api/redis-clusters/{id}/configure — reconfigure settings.
group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/configure", "PUT", ct);
});
// GET /api/redis-clusters/{id}/health — live health from K8s.
// The BFF resolves credentials server-side and forwards to Provisioning
// via POST (kubeConfig is too large for query strings).
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/redis-clusters/{id}/health", ct);
});
// POST /api/redis-clusters/{id}/restart — rolling restart.
group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/restart", "POST", ct);
});
// POST /api/redis-clusters/{id}/failover — controlled switchover.
group.MapPost("{id:guid}/failover", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/failover", "POST", ct);
});
// DELETE /api/redis-clusters/{id} — delete a Redis instance.
group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/redis-clusters/{id}", ct, "DELETE");
});
// GET /api/redis-clusters/available-versions — list supported Redis versions.
group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, "/api/redis-clusters/available-versions", ct);
});
}
/// <summary>
/// Forwards a request to Provisioning by injecting kubeConfig+contextName into
/// the body as a POST. This avoids putting large kubeConfig data into query
/// strings, which can exceed URL length limits.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct, string httpMethod = "POST")
{
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "DELETE"
? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
// Resolve kubeConfig from the Clusters service.
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Fetches kubeConfig and contextName from the Clusters service for a given
/// Kubernetes cluster ID. This is a server-to-server call — the frontend
/// never sees the kubeConfig.
/// </summary>
private static async Task<ClusterCreds?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
JsonNode? credsBody = await JsonNode.ParseAsync(
await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue<string>();
string? contextName = credsBody?["data"]?["contextName"]?.GetValue<string>();
if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName))
{
return null;
}
return new ClusterCreds(kubeConfig, contextName);
}
private record ClusterCreds(string KubeConfig, string ContextName);
}

View File

@@ -0,0 +1,60 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for service provisioning. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
/// </summary>
public static class ServicesProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/services")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/services — list all services (optionally filtered by clusterId)
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/services?clusterId={clusterId.Value}"
: "/api/services";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/services — provision a new service
group.MapPost("", async (ProvisionServiceRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/services", request, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/services/{id}/decommission — request decommissioning
group.MapPost("{id:guid}/decommission", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsync($"/api/services/{id}/decommission", null, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
}
}
public record ProvisionServiceRequest(Guid ClusterId, string ServiceType, string Name, string Namespace);

View File

@@ -0,0 +1,180 @@
namespace EntKube.Web.Components.Secrets;
/// <summary>
/// BFF proxy endpoints for the Secrets microservice. All vault operations
/// are tenant-scoped — the frontend passes the tenantId in the URL path.
/// </summary>
public static class SecretsProxyEndpoints
{
private static async Task<IResult> ForwardResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
}
private static async Task<IResult> SafeProxyAsync(Func<Task<IResult>> action)
{
try
{
return await action();
}
catch (HttpRequestException)
{
return Results.Json(
new { Success = false, Error = "Secrets service is unreachable. Make sure it is running on the configured port." },
statusCode: 502);
}
}
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/vault")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/vault/{tenantId}/status — tenant vault status
group.MapGet("{tenantId:guid}/status", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/status", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/init — initialize tenant vault
group.MapPost("{tenantId:guid}/init", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/init", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/seal — seal tenant vault
group.MapPost("{tenantId:guid}/seal", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/seal", null, ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/seal — seal all tenants
group.MapPost("seal", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.PostAsync("/api/vault/seal", null, ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/unseal — provide unseal share
group.MapPost("{tenantId:guid}/unseal", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/unseal", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// GET /api/vault/{tenantId}/secrets — list secret paths
group.MapGet("{tenantId:guid}/secrets", async (Guid tenantId, string? prefix, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
string url = string.IsNullOrEmpty(prefix)
? $"/api/vault/{tenantId}/secrets"
: $"/api/vault/{tenantId}/secrets?prefix={Uri.EscapeDataString(prefix)}";
HttpResponseMessage response = await client.GetAsync(url, ct);
return await ForwardResponseAsync(response, ct);
});
});
// GET /api/vault/{tenantId}/secrets/{path} — get a secret
group.MapGet("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/secrets/{path}", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/secrets/{path} — put a secret
group.MapPost("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/secrets/{path}", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// DELETE /api/vault/{tenantId}/secrets/{path} — delete a secret
group.MapDelete("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/vault/{tenantId}/secrets/{path}", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/tokens — create a service token
group.MapPost("{tenantId:guid}/tokens", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/tokens", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// GET /api/vault/{tenantId}/tokens — list service tokens
group.MapGet("{tenantId:guid}/tokens", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/tokens", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/tokens/revoke — revoke a token (global, token hash is unique)
group.MapPost("tokens/revoke", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync("/api/vault/tokens/revoke", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
}
}

Binary file not shown.

View File

@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 5000
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/EntKube.Web/EntKube.Web.csproj", "src/EntKube.Web/"]
COPY ["src/EntKube.Web.Client/EntKube.Web.Client.csproj", "src/EntKube.Web.Client/"]
COPY ["src/EntKube.SharedKernel/EntKube.SharedKernel.csproj", "src/EntKube.SharedKernel/"]
RUN dotnet restore "src/EntKube.Web/EntKube.Web.csproj"
COPY . .
WORKDIR "/src/src/EntKube.Web"
RUN dotnet build -c Release -o /app/build
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENV ASPNETCORE_URLS=http://+:5000
ENTRYPOINT ["dotnet", "EntKube.Web.dll"]

View File

@@ -56,6 +56,41 @@ public class Program
.AddSignInManager()
.AddDefaultTokenProviders();
// ConfigureApplicationCookie MUST come after AddIdentityCore + AddSignInManager
// because those calls internally configure cookie options and would overwrite
// earlier event handlers. Override the redirect events so /api/* paths get
// 401/403 JSON instead of a redirect to the login page (which is HTML and
// causes "'<' is an invalid start of a value" in the Blazor WASM deserializer).
builder.Services.ConfigureApplicationCookie(options =>
{
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
return context.Response.WriteAsync("{\"success\":false,\"error\":\"Authentication required.\"}");
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = 403;
context.Response.ContentType = "application/json";
return context.Response.WriteAsync("{\"success\":false,\"error\":\"Access denied.\"}");
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
});
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
// Register HTTP clients for communicating with backend microservices.
@@ -64,11 +99,13 @@ public class Program
builder.Services.AddHttpClient("ClustersApi", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:Clusters:BaseUrl"] ?? "https://localhost:5010");
client.Timeout = TimeSpan.FromMinutes(10);
});
builder.Services.AddHttpClient("ProvisioningApi", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:Provisioning:BaseUrl"] ?? "https://localhost:5020");
client.Timeout = TimeSpan.FromMinutes(10);
});
builder.Services.AddHttpClient("IdentityApi", client =>
@@ -76,6 +113,45 @@ public class Program
client.BaseAddress = new Uri(builder.Configuration["Services:Identity:BaseUrl"] ?? "https://localhost:5030");
});
builder.Services.AddHttpClient("SecretsApi", client =>
{
client.BaseAddress = new Uri(builder.Configuration["Services:Secrets:BaseUrl"] ?? "https://localhost:5040");
});
// Register a default HttpClient for Blazor interactive components running
// in Server mode (InteractiveAuto). WASM components get their own HttpClient
// from the WASM host, but when the same components run server-side they need
// an HttpClient with BaseAddress pointing back to this server so they can
// call the BFF proxy endpoints (e.g., /api/clusters, /api/vault).
//
// Problem: IHttpContextAccessor.HttpContext is null during Blazor Server's
// SignalR circuit, so the server-side HttpClient can't forward auth cookies
// for loopback calls. Solution: generate a random loopback token at startup.
// The server-side HttpClient sends it as a header; middleware on /api/*
// recognizes it and bypasses cookie auth. This is safe because the token
// is only known to this process and never leaves the server.
string loopbackToken = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
builder.Services.AddSingleton(new LoopbackAuthToken(loopbackToken));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped(sp =>
{
LoopbackAuthToken token = sp.GetRequiredService<LoopbackAuthToken>();
HttpClientHandler innerHandler = new() { UseCookies = false };
LoopbackTokenHandler handler = new(innerHandler, token.Token);
return new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost:5000"),
Timeout = TimeSpan.FromMinutes(15)
};
});
builder.Services.AddHealthChecks();
WebApplication app = builder.Build();
// Apply pending database migrations on startup with retry logic.
@@ -95,9 +171,88 @@ public class Program
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
// Return HTML error pages only for non-API routes. API routes (/api/*)
// must always return JSON — without this check, bare status codes from
// proxy endpoints get re-executed as "/not-found" HTML, which causes
// "'<' is an invalid start of a value" errors in the Blazor WASM client.
app.UseStatusCodePages(context =>
{
if (context.HttpContext.Request.Path.StartsWithSegments("/api"))
{
return Task.CompletedTask;
}
context.HttpContext.Response.Redirect($"/not-found");
return Task.CompletedTask;
});
// Catch unhandled exceptions on /api/* routes and return a JSON error
// instead of an HTML error page. Backend services may be unreachable
// during local development, and HttpRequestException must not propagate
// into the Blazor deserializer.
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
try
{
await next();
}
catch (HttpRequestException)
{
context.Response.StatusCode = 502;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(
"{\"success\":false,\"error\":\"Backend service is unreachable. Make sure all required services are running.\"}");
}
}
else
{
await next();
}
});
app.UseHttpsRedirection();
// Explicitly call UseAuthentication so that auto-insertion is disabled.
// This lets us place our loopback middleware AFTER authentication
// (which populates context.User from cookies) but BEFORE authorization
// (which checks context.User against [Authorize] / RequireAuthorization).
app.UseAuthentication();
// Loopback auth bypass: when Blazor Server components make HTTP calls
// back to this server's /api/* endpoints, they can't forward the user's
// auth cookie (HttpContext is null in SignalR circuits). Instead, the
// server-side HttpClient sends a secret loopback token. This middleware
// recognizes that token and overrides the unauthenticated principal
// with a trusted identity. The token is generated at startup and never
// leaves the process.
LoopbackAuthToken loopbackAuthToken = app.Services.GetRequiredService<LoopbackAuthToken>();
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api")
&& context.Request.Headers.TryGetValue("X-Loopback-Token", out Microsoft.Extensions.Primitives.StringValues tokenValue)
&& tokenValue == loopbackAuthToken.Token)
{
// Trusted server-to-self call — override the unauthenticated
// principal that UseAuthentication set (no cookie was present)
// with a dummy identity that UseAuthorization will accept.
System.Security.Claims.ClaimsIdentity identity = new("LoopbackAuth");
identity.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "blazor-server"));
context.User = new System.Security.Claims.ClaimsPrincipal(identity);
}
await next();
});
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
@@ -111,6 +266,27 @@ public class Program
app.MapAdditionalIdentityEndpoints();
// Map BFF proxy endpoints that forward requests to backend microservices.
EntKube.Web.Components.Clusters.ClustersProxyEndpoints.Map(app);
EntKube.Web.Components.Clusters.CertificateProxyEndpoints.Map(app);
EntKube.Web.Components.Identity.TenantsProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.ServicesProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.MinioTenantProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.KeycloakRealmProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.PostgresClusterProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.RedisClusterProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.MongoClusterProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.PrometheusProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.GrafanaProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.AppsProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.HarborProxyEndpoints.Map(app);
EntKube.Web.Components.Provisioning.GiteaProxyEndpoints.Map(app);
EntKube.Web.Components.Secrets.SecretsProxyEndpoints.Map(app);
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready");
app.Run();
}
@@ -151,3 +327,33 @@ public class Program
}
}
}
/// <summary>
/// Holds the randomly-generated loopback token. Registered as singleton so the
/// same token is shared between the middleware (validation) and the HttpClient
/// handler (sending). The token is generated at startup and never persisted.
/// </summary>
internal sealed record LoopbackAuthToken(string Token);
/// <summary>
/// Attaches the loopback token to every outgoing request from the server-side
/// HttpClient. The middleware recognizes this token and bypasses cookie auth,
/// allowing Blazor Server components to call /api/* endpoints without cookies.
/// </summary>
internal sealed class LoopbackTokenHandler : DelegatingHandler
{
private readonly string token;
public LoopbackTokenHandler(HttpMessageHandler innerHandler, string token)
: base(innerHandler)
{
this.token = token;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.TryAddWithoutValidation("X-Loopback-Token", token);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -1,25 +1,25 @@
{
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5086",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7133;http://localhost:5086",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7000;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -4,5 +4,19 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Services": {
"Clusters": {
"BaseUrl": "http://localhost:5010"
},
"Provisioning": {
"BaseUrl": "http://localhost:5020"
},
"Identity": {
"BaseUrl": "http://localhost:5030"
},
"Secrets": {
"BaseUrl": "http://localhost:5040"
}
}
}