diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs index dc969979..cfaaab6d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs @@ -3,8 +3,10 @@ using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Api; @@ -31,7 +33,8 @@ public static class DeployApiEndpoints /// 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 + /// Maps POST /api/deployments — the headless deploy trigger — and + /// GET /api/deployments/{id}, the status resource its 202 Location points at. 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). @@ -62,9 +65,61 @@ public static class DeployApiEndpoints .DisableAntiforgery() // machine endpoint, not a browser form post .WithName("StartDeployment"); + // The status resource the POST's 202 Location points at (R04 S5). Same gate as the POST: the + // client that follows the Location is the automation holding the deploy key, not a browser + // session, so it is API-key gated (AllowAnonymous + the key check) rather than cookie-policy + // gated, and disabled (503) while the key is unconfigured. + app.MapGet("/api/deployments/{deploymentId:guid}", async ( + HttpContext http, + Guid deploymentId, + IDbContextFactory dbFactory, + 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(); + + await using var db = await dbFactory.CreateDbContextAsync(ct); + var status = await GetDeploymentStatusAsync(db, deploymentId, ct); + return status is null ? Results.NotFound() : Results.Ok(status); + }) + .AllowAnonymous() // gated by the API key, not the cookie auth fallback + .WithName("GetDeployment"); + return app; } + /// Reads one deployment and its per-node apply states, or null when the id is unknown. + /// The config DB context. + /// The deployment id from the POST's response / Location. + /// Cancellation token. + /// The status body, or null. + public static async Task GetDeploymentStatusAsync( + OtOpcUaConfigDbContext db, Guid deploymentId, CancellationToken ct = default) + { + // Projected: the deployment row also carries the (large) artifact blob, which a status poll never needs. + var deployment = await db.Deployments.AsNoTracking() + .Where(d => d.DeploymentId == deploymentId) + .Select(d => new { d.DeploymentId, d.RevisionHash, d.Status, d.CreatedBy, d.CreatedAtUtc, d.SealedAtUtc, d.FailureReason }) + .FirstOrDefaultAsync(ct); + if (deployment is null) return null; + + var nodes = await db.NodeDeploymentStates.AsNoTracking() + .Where(n => n.DeploymentId == deploymentId) + .OrderBy(n => n.NodeId) + .Select(n => new NodeDeploymentStatusResponse( + n.NodeId, n.Status.ToString(), n.StartedAtUtc, n.AppliedAtUtc, n.FailureReason)) + .ToListAsync(ct); + + return new DeploymentStatusResponse( + deployment.DeploymentId, deployment.RevisionHash, deployment.Status.ToString(), deployment.CreatedBy, + deployment.CreatedAtUtc, deployment.SealedAtUtc, deployment.FailureReason, nodes); + } + /// 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). @@ -106,4 +161,26 @@ public static class DeployApiEndpoints /// 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); + + /// Response body for GET /api/deployments/{id}. + /// The deployment id. + /// The config revision hash the deployment carries. + /// The deployment's DeploymentStatus name. + /// Who/what initiated it. + /// When it was created. + /// When it sealed; null while still dispatching/applying. + /// The deployment-level failure reason, when any. + /// Per-node apply states, ordered by node id. + public sealed record DeploymentStatusResponse( + Guid DeploymentId, string RevisionHash, string Status, string CreatedBy, DateTime CreatedAtUtc, + DateTime? SealedAtUtc, string? FailureReason, IReadOnlyList Nodes); + + /// One node's apply state within a . + /// The cluster node id. + /// The node's NodeDeploymentStatus name. + /// When the node started applying. + /// When the node acked; null while applying. + /// The node's failure (or "Applied degraded: …") reason, when any. + public sealed record NodeDeploymentStatusResponse( + string NodeId, string Status, DateTime StartedAtUtc, DateTime? AppliedAtUtc, string? FailureReason); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeploymentStatusEndpointTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeploymentStatusEndpointTests.cs new file mode 100644 index 00000000..bf305d96 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeploymentStatusEndpointTests.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Api; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api; + +/// +/// R04 S5: the POST's 202 Location points at GET /api/deployments/{id}, which now exists, +/// carries the same API-key gate as the POST, and reports the deployment plus its per-node states. +/// +[Trait("Category", "Unit")] +public sealed class DeploymentStatusEndpointTests +{ + private static OtOpcUaConfigDbContext NewDb(string name) => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + [Fact] + public async Task GetDeploymentStatusAsync_returns_the_deployment_and_its_node_states() + { + var name = Guid.NewGuid().ToString("N"); + var id = Guid.NewGuid(); + await using (var seed = NewDb(name)) + { + seed.Deployments.Add(new Deployment + { + DeploymentId = id, RevisionHash = "rev1", CreatedBy = "ci", Status = DeploymentStatus.Dispatching, + ArtifactBlob = new byte[1024], + }); + seed.Deployments.Add(new Deployment { DeploymentId = Guid.NewGuid(), RevisionHash = "other", CreatedBy = "x" }); + seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-b", DeploymentId = id, Status = NodeDeploymentStatus.Applying }); + seed.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = "node-a", DeploymentId = id, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow }); + await seed.SaveChangesAsync(); + } + + await using var db = NewDb(name); + var status = await DeployApiEndpoints.GetDeploymentStatusAsync(db, id); + + status.ShouldNotBeNull(); + status!.DeploymentId.ShouldBe(id); + status.RevisionHash.ShouldBe("rev1"); + status.Status.ShouldBe(nameof(DeploymentStatus.Dispatching)); + status.Nodes.Select(n => (n.NodeId, n.Status)).ShouldBe( + [("node-a", nameof(NodeDeploymentStatus.Applied)), ("node-b", nameof(NodeDeploymentStatus.Applying))]); + } + + [Fact] + public async Task GetDeploymentStatusAsync_returns_null_for_an_unknown_id() + { + await using var db = NewDb(Guid.NewGuid().ToString("N")); + (await DeployApiEndpoints.GetDeploymentStatusAsync(db, Guid.NewGuid())).ShouldBeNull(); + } + + [Fact] + public void Get_route_exists_and_is_gated_like_the_post() + { + var endpoints = MapDeployApi(); + + var get = endpoints.Single(e => NameOf(e) == "GetDeployment"); + get.RoutePattern.RawText.ShouldBe("/api/deployments/{deploymentId:guid}"); + get.Metadata.GetMetadata()!.HttpMethods.ShouldBe(["GET"]); + + var post = endpoints.Single(e => NameOf(e) == "StartDeployment"); + + // Both are API-key gated in the handler: AllowAnonymous so the cookie fallback policy does not 401 + // the automation client, and no cookie policy of their own. + foreach (var e in new[] { get, post }) + { + e.Metadata.GetMetadata().ShouldNotBeNull(NameOf(e)); + e.Metadata.GetOrderedMetadata().Where(a => !string.IsNullOrEmpty(a.Policy)).ShouldBeEmpty(NameOf(e)); + } + } + + [Fact] + public void Accepted_location_matches_the_get_route() + { + var id = Guid.NewGuid(); + var result = DeployApiEndpoints.ToResult(new StartDeploymentResult( + StartDeploymentOutcome.Accepted, new DeploymentId(id), new RevisionHash("rev"), null, CorrelationId.NewId())); + + var accepted = result.ShouldBeOfType>(); + accepted.Location.ShouldBe($"/api/deployments/{id}"); + Guid.TryParse(accepted.Location!["/api/deployments/".Length..], out _).ShouldBeTrue(); + } + + private static string? NameOf(Endpoint e) => e.Metadata.GetMetadata()?.EndpointName; + + private static IReadOnlyList MapDeployApi() + { + var builder = WebApplication.CreateBuilder(); + // Registered only so the handlers' service parameters are inferred as services; the metadata + // scan below never invokes a handler, so nothing is resolved. + builder.Services.AddSingleton(_ => null!); + builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase("deploy-api-metadata")); + var app = builder.Build(); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [DeployApiEndpoints.ConfigKey] = "k" }) + .Build(); + app.MapOtOpcUaDeployApi(config); + return ((IEndpointRouteBuilder)app).DataSources + .SelectMany(d => d.Endpoints) + .OfType() + .ToList(); + } +}