using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Api;
///
/// Headless REST gateway over the admin-operations cluster singleton, for CI / scripts that
/// need to trigger a deployment without driving the Blazor AdminUI. Mounted on admin-role nodes
/// only (where + the singleton proxy live), it forwards to
/// the same the "Deploy current
/// configuration" button calls.
///
/// Auth is a single configured secret (Security:DeployApiKey) checked from the
/// X-Api-Key header in fixed time — deliberately orthogonal to the cookie-only web
/// auth so automation needs no LDAP login round-trip. The endpoint is AllowAnonymous
/// (so the global RequireAuthenticatedUser fallback doesn't 401 it) and enforces the
/// key itself; when the key is unconfigured the endpoint is disabled (503), so it is never
/// open.
///
///
public static class DeployApiEndpoints
{
/// Header carrying the deploy API key.
public const string ApiKeyHeader = "X-Api-Key";
/// Configuration key holding the deploy API secret.
public const string ConfigKey = "Security:DeployApiKey";
/// Maps POST /api/deployments — the headless deploy trigger. No-op-safe to call
/// on any admin-role node; the handler self-disables (503) until Security:DeployApiKey is set.
/// The endpoint route builder.
/// Application configuration (read for the deploy API key).
/// The same for chaining.
public static IEndpointRouteBuilder MapOtOpcUaDeployApi(this IEndpointRouteBuilder app, IConfiguration config)
{
var configuredKey = config[ConfigKey];
app.MapPost("/api/deployments", async (
HttpContext http,
DeployRequest? body,
IAdminOperationsClient adminOps,
CancellationToken ct) =>
{
if (string.IsNullOrEmpty(configuredKey))
return Results.Problem(
"Deploy API is disabled. Set Security:DeployApiKey to enable it.",
statusCode: StatusCodes.Status503ServiceUnavailable);
if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey))
return Results.Unauthorized();
var createdBy = string.IsNullOrWhiteSpace(body?.CreatedBy) ? "rest-api" : body!.CreatedBy!.Trim();
var result = await adminOps.StartDeploymentAsync(createdBy, ct);
return ToResult(result);
})
.AllowAnonymous() // gated by the API key, not the cookie auth fallback
.DisableAntiforgery() // machine endpoint, not a browser form post
.WithName("StartDeployment");
return app;
}
/// Fixed-time compare of the supplied key against the configured secret. False when
/// either is empty (so an unconfigured key never authorizes).
/// The caller-supplied key (from the X-Api-Key header).
/// The configured secret.
/// only when both are non-empty and equal.
public static bool IsAuthorized(string? provided, string? configuredKey)
{
if (string.IsNullOrEmpty(configuredKey) || string.IsNullOrEmpty(provided)) return false;
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(configuredKey));
}
/// Map a to its HTTP result: Accepted → 202 (with a
/// Location), NoChanges → 200, AnotherDeploymentInFlight → 409, Rejected → 422, anything else → 500.
/// The deployment outcome from the admin-operations singleton.
/// The corresponding HTTP result.
public static IResult ToResult(StartDeploymentResult r) => r.Outcome switch
{
StartDeploymentOutcome.Accepted => Results.Accepted(
$"/api/deployments/{r.DeploymentId?.Value}",
new DeployResponse(r.Outcome.ToString(), r.DeploymentId?.Value, r.RevisionHash?.Value)),
StartDeploymentOutcome.NoChanges => Results.Ok(
new DeployResponse(r.Outcome.ToString(), r.DeploymentId?.Value, r.RevisionHash?.Value)),
StartDeploymentOutcome.AnotherDeploymentInFlight => Results.Conflict(
new DeployResponse(r.Outcome.ToString(), DeploymentId: null, RevisionHash: null)),
StartDeploymentOutcome.Rejected => Results.Problem(
r.Message ?? "Deployment rejected.", statusCode: StatusCodes.Status422UnprocessableEntity),
_ => Results.Problem($"Unexpected deployment outcome: {r.Outcome}",
statusCode: StatusCodes.Status500InternalServerError),
};
/// Optional request body. CreatedBy is recorded as the deployment's initiator
/// (audit); defaults to "rest-api" when absent.
/// Who/what initiated the deploy (for the audit trail).
public sealed record DeployRequest(string? CreatedBy);
/// Response body for a deploy trigger.
/// The name.
/// The new deployment id when one was created; otherwise null.
/// The config revision hash, when known.
public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash);
}