too much for one commit
This commit is contained in:
@@ -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 />
|
||||
|
||||
135
src/EntKube.Web/Components/Clusters/CertificateProxyEndpoints.cs
Normal file
135
src/EntKube.Web/Components/Clusters/CertificateProxyEndpoints.cs
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
381
src/EntKube.Web/Components/Clusters/ClustersProxyEndpoints.cs
Normal file
381
src/EntKube.Web/Components/Clusters/ClustersProxyEndpoints.cs
Normal 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);
|
||||
132
src/EntKube.Web/Components/Identity/TenantsProxyEndpoints.cs
Normal file
132
src/EntKube.Web/Components/Identity/TenantsProxyEndpoints.cs
Normal 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);
|
||||
381
src/EntKube.Web/Components/Provisioning/AppsProxyEndpoints.cs
Normal file
381
src/EntKube.Web/Components/Provisioning/AppsProxyEndpoints.cs
Normal 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);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
153
src/EntKube.Web/Components/Provisioning/GrafanaProxyEndpoints.cs
Normal file
153
src/EntKube.Web/Components/Provisioning/GrafanaProxyEndpoints.cs
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
180
src/EntKube.Web/Components/Secrets/SecretsProxyEndpoints.cs
Normal file
180
src/EntKube.Web/Components/Secrets/SecretsProxyEndpoints.cs
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user