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